Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
319 changes: 304 additions & 15 deletions formulus-formplayer/src/FinalizeRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import React from 'react';
import { Box, Button, List, ListItem, ListItemText, Typography, Paper } from '@mui/material';
import React, { useMemo } from 'react';
import { Box, Button, List, ListItem, ListItemText, Typography, Paper, Divider, Link } from '@mui/material';
import { JsonFormsRendererRegistryEntry } from '@jsonforms/core';
import { withJsonFormsControlProps, useJsonForms } from '@jsonforms/react';
import { ControlProps } from '@jsonforms/core';
import { ErrorObject } from 'ajv';
import { useFormContext } from './App';
import EditIcon from '@mui/icons-material/Edit';

interface SummaryItem {
label: string;
value: any;
path: string;
pageIndex: number;
type?: string;
format?: string;
}

const FinalizeRenderer = ({
schema,
Expand All @@ -19,10 +29,186 @@ const FinalizeRenderer = ({
const { core } = useJsonForms();
const errors = core?.errors || [];
const { formInitData } = useFormContext();
const fullSchema = core?.schema;
const fullUISchema = formInitData?.uiSchema;

// Log the props to inspect their structure
console.log('JsonForms props:', { errors, data, path, cells, schema });

// Helper function to get field label from schema
const getFieldLabel = (fieldPath: string, fieldSchema: any): string => {
if (!fieldSchema) return fieldPath;
return fieldSchema.title || fieldSchema.description || fieldPath.split('/').pop() || fieldPath;
};

// Helper function to format field value based on type
const formatFieldValue = (value: any, fieldSchema: any): string => {
if (value === null || value === undefined || value === '') {
return 'Not provided';
}

// Handle special formats
if (fieldSchema?.format) {
switch (fieldSchema.format) {
case 'photo':
if (typeof value === 'object' && value.uri) {
return `Photo: ${value.filename || 'Captured'}`;
}
return 'Photo captured';
case 'qrcode':
if (typeof value === 'object' && value.data) {
return `QR Code: ${value.data}`;
}
return typeof value === 'string' ? `QR Code: ${value}` : 'QR Code scanned';
case 'signature':
if (typeof value === 'object' && value.uri) {
return 'Signature captured';
}
return 'Signature provided';
case 'select_file':
if (typeof value === 'object' && value.filename) {
return `File: ${value.filename}`;
}
return 'File selected';
case 'audio':
if (typeof value === 'object' && value.filename) {
const duration = value.metadata?.duration ? ` (${Math.round(value.metadata.duration)}s)` : '';
return `Audio: ${value.filename}${duration}`;
}
return 'Audio recorded';
case 'gps':
if (typeof value === 'object' && value.latitude && value.longitude) {
return `Location: ${value.latitude.toFixed(6)}, ${value.longitude.toFixed(6)}`;
}
return 'GPS location captured';
case 'video':
if (typeof value === 'object' && value.filename) {
return `Video: ${value.filename}`;
}
return 'Video captured';
case 'date':
return new Date(value).toLocaleDateString();
case 'date-time':
return new Date(value).toLocaleString();
case 'time':
return value;
}
}

// Handle arrays
if (Array.isArray(value)) {
if (value.length === 0) return 'None';
return value.map((item, idx) => {
if (typeof item === 'object') {
return `${idx + 1}. ${JSON.stringify(item)}`;
}
return String(item);
}).join(', ');
}

// Handle objects
if (typeof value === 'object') {
// Check if it's a nested object with properties
if (Object.keys(value).length === 0) return 'Empty';
return JSON.stringify(value, null, 2);
}

// Handle booleans
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}

// Default: convert to string
return String(value);
};


// Helper function to find which page/screen a field is on
const findFieldPageMemo = useMemo(() => {
return (fieldPath: string): number => {
if (!fullUISchema || !fullUISchema.elements) return -1;

// Normalize the field path (remove #/properties/ prefix and convert / to .)
const normalizePath = (path: string) => {
return path.replace(/^#\/properties\//, '').replace(/\//g, '.');
};

const fieldName = normalizePath(fieldPath);
const screens = fullUISchema.elements;

for (let i = 0; i < screens.length; i++) {
const screen = screens[i];
if (screen.type === 'Finalize') continue;

if ('elements' in screen && screen.elements) {
const hasField = screen.elements.some((el: any) => {
if (el.scope) {
const scopePath = normalizePath(el.scope);
// Exact match or field is nested under scope, or scope is nested under field
return scopePath === fieldName ||
fieldName.startsWith(scopePath + '.') ||
scopePath.startsWith(fieldName + '.');
}
return false;
});

if (hasField) return i;
}
}

return -1;
};
}, [fullUISchema]);

// Extract all form fields and their values for summary
const summaryItems = useMemo((): SummaryItem[] => {
if (!fullSchema || !data || !fullSchema.properties) return [];

const items: SummaryItem[] = [];

const extractFields = (schemaObj: any, dataObj: any, basePath: string = '') => {
if (!schemaObj || !schemaObj.properties) return;

Object.keys(schemaObj.properties).forEach(key => {
const fieldSchema = schemaObj.properties[key];
const fieldPath = basePath ? `${basePath}/${key}` : key;
const fieldValue = dataObj?.[key];
const fullPath = `#/properties/${fieldPath}`;

// Skip if value is empty (null, undefined, empty string, empty array, empty object)
const isEmpty =
fieldValue === null ||
fieldValue === undefined ||
fieldValue === '' ||
(Array.isArray(fieldValue) && fieldValue.length === 0) ||
(typeof fieldValue === 'object' && !Array.isArray(fieldValue) && Object.keys(fieldValue).length === 0);

if (isEmpty) {
// Only include empty fields if they are required (to show what's missing)
const isRequired = schemaObj.required?.includes(key);
if (!isRequired) return;
}

// Handle nested objects
if (fieldSchema.type === 'object' && fieldSchema.properties && typeof fieldValue === 'object' && !Array.isArray(fieldValue)) {
extractFields(fieldSchema, fieldValue, fieldPath);
} else {
// Add to summary
const pageIndex = findFieldPageMemo(fullPath);
items.push({
label: getFieldLabel(fullPath, fieldSchema),
value: fieldValue,
path: fullPath,
pageIndex,
type: fieldSchema.type,
format: fieldSchema.format
});
}
});
};

extractFields(fullSchema, data);

return items;
}, [fullSchema, data, findFieldPageMemo]);

const formatErrorPath = (path: string) => {
// Remove leading slash and convert to readable format
return path.replace(/^\//, '').replace(/\//g, ' > ');
Expand Down Expand Up @@ -52,6 +238,19 @@ const FinalizeRenderer = ({
window.dispatchEvent(event);
};

const handleFieldEdit = (item: SummaryItem) => {
if (item.pageIndex >= 0) {
// Navigate to the page containing this field
const navigateEvent = new CustomEvent('navigateToPage', {
detail: { page: item.pageIndex }
});
window.dispatchEvent(navigateEvent);
} else {
// Fallback: try to navigate using the field path
handleErrorClick(item.path);
}
};

const handleFinalize = () => {
if (!formInitData) {
console.error('formInitData is not available from context, cannot submit form');
Expand All @@ -65,7 +264,7 @@ const FinalizeRenderer = ({
};

return (
<Box sx={{ p: 3 }}>
<Box sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" gutterBottom>
Review and Finalize
</Typography>
Expand Down Expand Up @@ -98,15 +297,105 @@ const FinalizeRenderer = ({
</Typography>
)}

<Button
variant="contained"
color="primary"
size="large"
onClick={handleFinalize}
disabled={Boolean(hasErrors)}
>
Finalize
</Button>
{/* Summary Section */}
{summaryItems.length > 0 && (
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', mb: 3 }}>
<Typography variant="h6" gutterBottom sx={{ mt: 2, mb: 1 }}>
Form Summary
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom sx={{ mb: 2 }}>
Review all your entered data below. Click on any field to edit it.
</Typography>
<Paper
sx={{
flex: 1,
overflow: 'auto',
p: 2,
maxHeight: '100%'
}}
>
<List sx={{ width: '100%' }}>
{summaryItems.map((item, index) => (
<React.Fragment key={index}>
<ListItem
sx={{
flexDirection: 'column',
alignItems: 'stretch',
py: 1.5,
px: 0,
'&:hover': {
backgroundColor: 'action.hover',
borderRadius: 1
}
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', width: '100%' }}>
<Box sx={{ flex: 1, minWidth: 0, mr: 2 }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: 600,
mb: 0.5,
wordBreak: 'break-word'
}}
>
{item.label}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{
wordBreak: 'break-word',
whiteSpace: 'pre-wrap'
}}
>
{formatFieldValue(item.value, { type: item.type, format: item.format })}
</Typography>
</Box>
{item.pageIndex >= 0 && (
<Link
component="button"
variant="body2"
onClick={() => handleFieldEdit(item)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
cursor: 'pointer',
textDecoration: 'none',
color: 'primary.main',
'&:hover': {
textDecoration: 'underline'
},
flexShrink: 0
}}
>
<EditIcon sx={{ fontSize: 16 }} />
Edit
</Link>
)}
</Box>
</ListItem>
{index < summaryItems.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
</Paper>
</Box>
)}

<Box sx={{ mt: 'auto', pt: 2 }}>
<Button
variant="contained"
color="primary"
size="large"
fullWidth
onClick={handleFinalize}
disabled={Boolean(hasErrors)}
>
Finalize
</Button>
</Box>
</Box>
);
};
Expand Down