diff --git a/src/App.tsx b/src/App.tsx index e3f2da9..24d5eb7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,7 +39,7 @@ import { FeatureLayout } from './layouts/FeatureLayout'; import { DashboardPage } from './pages/Dashboard'; import { SettingsPage } from './pages/Settings'; import { FeatureViewPage } from './pages/FeatureView'; -import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage } from './pages/admin'; +import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage } from './pages/admin'; // Workflow Pages (global) import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows'; @@ -168,6 +168,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/api/trusteeApi.ts b/src/api/trusteeApi.ts index bd97bf6..9ea5a53 100644 --- a/src/api/trusteeApi.ts +++ b/src/api/trusteeApi.ts @@ -519,10 +519,29 @@ export async function createDocument( instanceId: string, data: Partial ): Promise { + // If documentData is a File, convert to base64 + let processedData = { ...data }; + if (data.documentData instanceof File) { + const file = data.documentData as File; + const arrayBuffer = await file.arrayBuffer(); + const base64 = btoa( + new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), '') + ); + processedData.documentData = base64 as any; + // Auto-set MIME type from file if not provided + if (!processedData.documentMimeType && file.type) { + processedData.documentMimeType = file.type; + } + // Auto-set name from file if not provided + if (!processedData.documentName && file.name) { + processedData.documentName = file.name; + } + } + return await request({ url: `${_getTrusteeBaseUrl(instanceId)}/documents`, method: 'post', - data + data: processedData }); } diff --git a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx index 640f143..7566418 100644 --- a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx +++ b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useLanguage } from '../../../providers/language/LanguageContext'; import api from '../../../api'; import styles from './FormGeneratorForm.module.css'; @@ -105,8 +105,19 @@ export function FormGeneratorForm>({ }: FormGeneratorFormProps) { const { t } = useLanguage(); - // Helper to resolve API paths with {instanceId} placeholder - const resolveApiPath = (path: string): string => { + const [formData, setFormData] = useState(data || {} as T); + const [errors, setErrors] = useState>({}); + const [fieldFocused, setFieldFocused] = useState>({}); + const [attributes, setAttributes] = useState(providedAttributes || []); + const [loadingAttributes, setLoadingAttributes] = useState(!providedAttributes); + const [optionsCache, setOptionsCache] = useState>>({}); + const [loadingOptions, setLoadingOptions] = useState>({}); + const [submitting, setSubmitting] = useState(false); + // Track which option keys have been fetched or are being fetched (using ref to avoid re-renders) + const fetchedOrFetchingOptions = useRef>(new Set()); + + // Helper to resolve API paths with {instanceId} placeholder - memoized to prevent useEffect re-runs + const resolveApiPath = useCallback((path: string): string => { if (path.includes('{instanceId}')) { const resolvedInstanceId = instanceId || (data as any)?.featureInstanceId; if (!resolvedInstanceId) { @@ -116,15 +127,7 @@ export function FormGeneratorForm>({ return path.replace('{instanceId}', resolvedInstanceId); } return path; - }; - const [formData, setFormData] = useState(data || {} as T); - const [errors, setErrors] = useState>({}); - const [fieldFocused, setFieldFocused] = useState>({}); - const [attributes, setAttributes] = useState(providedAttributes || []); - const [loadingAttributes, setLoadingAttributes] = useState(!providedAttributes); - const [optionsCache, setOptionsCache] = useState>>({}); - const [loadingOptions, setLoadingOptions] = useState>({}); - const [submitting, setSubmitting] = useState(false); + }, [instanceId, data]); // Fetch attributes from backend useEffect(() => { @@ -252,26 +255,39 @@ export function FormGeneratorForm>({ // Fetch options for fields with optionsReference (API path) // Backend provides options in standardized format: { value, label } + // OPTIMIZED: Only fetch options that are not already fetched or being fetched useEffect(() => { const fetchOptions = async () => { const filteredAttrs = getFilteredAttributes(); - const fieldsToFetch = filteredAttrs.filter(attr => { - if (typeof attr.options === 'string' && !optionsCache[attr.options]) { - return true; + + // Collect unique option keys that need fetching + const optionKeysToFetch: string[] = []; + filteredAttrs.forEach(attr => { + if (typeof attr.options === 'string') { + const optionKey = attr.options; + // Only fetch if not already fetched or being fetched (using ref for immediate check) + if (!fetchedOrFetchingOptions.current.has(optionKey)) { + optionKeysToFetch.push(optionKey); + // Immediately mark as being fetched to prevent duplicate requests + fetchedOrFetchingOptions.current.add(optionKey); + } } - return false; }); - if (fieldsToFetch.length === 0) return; + if (optionKeysToFetch.length === 0) return; - for (const field of fieldsToFetch) { - if (typeof field.options !== 'string') continue; - - const optionKey = field.options; - setLoadingOptions(prev => ({ ...prev, [field.name]: true })); - + // Set loading state for relevant fields + const fieldsLoading: Record = {}; + filteredAttrs.forEach(attr => { + if (typeof attr.options === 'string' && optionKeysToFetch.includes(attr.options)) { + fieldsLoading[attr.name] = true; + } + }); + setLoadingOptions(prev => ({ ...prev, ...fieldsLoading })); + + // Fetch all options in parallel + const fetchPromises = optionKeysToFetch.map(async (optionKey) => { try { - // Backend provides full API path (e.g., "/api/connections/statuses/options") // Resolve {instanceId} placeholder if present const apiPath = resolveApiPath(optionKey); const response = await api.get(apiPath); @@ -292,18 +308,37 @@ export function FormGeneratorForm>({ }); } - setOptionsCache(prev => ({ ...prev, [optionKey]: fetchedOptions })); + return { key: optionKey, options: fetchedOptions, error: null }; } catch (error: any) { - console.error(`Failed to fetch options for ${field.options}:`, error); - setOptionsCache(prev => ({ ...prev, [field.options as string]: [] })); - } finally { - setLoadingOptions(prev => ({ ...prev, [field.name]: false })); + console.error(`Failed to fetch options for ${optionKey}:`, error); + return { key: optionKey, options: [], error }; } - } + }); + + // Wait for all fetches to complete + const results = await Promise.all(fetchPromises); + + // Update cache with all results at once + setOptionsCache(prev => { + const newCache = { ...prev }; + results.forEach(({ key, options }) => { + newCache[key] = options; + }); + return newCache; + }); + + // Clear loading states + const fieldsNotLoading: Record = {}; + filteredAttrs.forEach(attr => { + if (typeof attr.options === 'string' && optionKeysToFetch.includes(attr.options)) { + fieldsNotLoading[attr.name] = false; + } + }); + setLoadingOptions(prev => ({ ...prev, ...fieldsNotLoading })); }; fetchOptions(); - }, [getFilteredAttributes, optionsCache, resolveApiPath]); + }, [getFilteredAttributes, resolveApiPath]); // Handle field focus const handleFieldFocus = (fieldName: string, focused: boolean) => { @@ -314,10 +349,22 @@ export function FormGeneratorForm>({ }; // Handle field value changes - const handleFieldChange = (fieldName: string, value: any) => { + // For timestamp fields: Convert datetime-local string to Unix timestamp (float in seconds) + const handleFieldChange = (fieldName: string, value: any, fieldType?: AttributeType) => { + let processedValue = value; + + // If field type is timestamp, convert datetime-local string to Unix timestamp + if (fieldType === 'timestamp' && typeof value === 'string' && value) { + const date = new Date(value); + if (!isNaN(date.getTime())) { + // Convert to Unix timestamp in seconds (float) + processedValue = date.getTime() / 1000; + } + } + setFormData(prev => ({ ...prev, - [fieldName]: value + [fieldName]: processedValue })); // Clear error for this field when user starts typing @@ -329,6 +376,24 @@ export function FormGeneratorForm>({ }); } }; + + // Convert Unix timestamp (seconds) to datetime-local input format + const timestampToDatetimeLocal = (timestamp: number | string | null | undefined): string => { + if (timestamp === null || timestamp === undefined || timestamp === '') { + return ''; + } + const numValue = typeof timestamp === 'string' ? parseFloat(timestamp) : timestamp; + if (isNaN(numValue)) { + return ''; + } + // Unix timestamp in seconds - convert to milliseconds for Date + const date = new Date(numValue * 1000); + if (isNaN(date.getTime())) { + return ''; + } + // Format as datetime-local: YYYY-MM-DDTHH:mm + return date.toISOString().slice(0, 16); + }; // Normalize options for a field const normalizeOptions = (attr: AttributeDefinition): Array<{ value: string | number; label: string }> => { @@ -430,8 +495,17 @@ export function FormGeneratorForm>({ // Timestamp/Date validation if (isDateTimeType(attr.type)) { - const dateValue = new Date(String(value)); - if (isNaN(dateValue.getTime())) { + // For timestamp fields, value is stored as Unix timestamp (float) + // For date/time fields, value is stored as string + let isValid = false; + if (attr.type === 'timestamp' && typeof value === 'number') { + // Unix timestamp in seconds - valid if it's a reasonable timestamp + isValid = value > 0 && value < 4102444800; // Before year 2100 + } else { + const dateValue = new Date(String(value)); + isValid = !isNaN(dateValue.getTime()); + } + if (!isValid) { newErrors[attr.name] = t('formgen.form.invalidDate', 'Invalid date format'); return; } @@ -836,26 +910,32 @@ export function FormGeneratorForm>({ ); } - // Default input field (text, email, date, time, url, password, number, integer, float) + // Default input field (text, email, date, time, url, password, number, integer, float, timestamp) const inputType = attributeTypeToInputType(attr.type); + + // For timestamp fields, convert Unix timestamp (float) to datetime-local format for display + const displayValue = attr.type === 'timestamp' + ? timestampToDatetimeLocal(value) + : (value || ''); return (
{ let newValue: any = e.target.value; if (isNumberType(attr.type)) { newValue = e.target.value === '' ? '' : Number(e.target.value); } - handleFieldChange(attr.name, newValue); + // Pass field type for timestamp conversion + handleFieldChange(attr.name, newValue, attr.type); }} onFocus={() => handleFieldFocus(attr.name, true)} onBlur={() => handleFieldFocus(attr.name, false)} className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`} /> -