diff --git a/formulus-formplayer/src/FormProgressBar.tsx b/formulus-formplayer/src/FormProgressBar.tsx new file mode 100644 index 000000000..73327ceaa --- /dev/null +++ b/formulus-formplayer/src/FormProgressBar.tsx @@ -0,0 +1,205 @@ +import React, { useMemo } from 'react'; +import { Box, LinearProgress, Typography } from '@mui/material'; + +type JsonSchema = { + type?: string | string[]; + properties?: Record; + [key: string]: any; +}; + +interface FormProgressBarProps { + /** + * Current page index (0-based) + */ + currentPage: number; + /** + * Total number of screens/pages in the form (including Finalize screen) + */ + totalScreens: number; + /** + * Form data to calculate progress based on answered questions + */ + data?: Record; + /** + * Form schema to identify all questions + */ + schema?: JsonSchema; + /** + * UI schema to identify screens + */ + uischema?: any; + /** + * Progress calculation mode: 'screens' or 'questions' + * 'screens': Based on screens completed + * 'questions': Based on questions answered + */ + mode?: 'screens' | 'questions' | 'both'; + /** + * Whether the user is currently on the Finalize page + */ + isOnFinalizePage?: boolean; +} + +/** + * Recursively count all question fields in the schema + */ +const countQuestions = (schema: JsonSchema | undefined, path: string = ''): number => { + if (!schema || !schema.properties) { + return 0; + } + + let count = 0; + const properties = schema.properties; + + for (const [key, value] of Object.entries(properties)) { + const currentPath = path ? `${path}.${key}` : key; + const fieldSchema = value as JsonSchema; + + if (fieldSchema.type === 'object' && fieldSchema.properties) { + count += countQuestions(fieldSchema, currentPath); + } else { + if (fieldSchema.format !== 'finalize') { + count++; + } + } + } + + return count; +}; + +/** + * Recursively count answered questions in the data + */ +const countAnsweredQuestions = ( + schema: JsonSchema | undefined, + data: Record, + path: string = '' +): number => { + if (!schema || !schema.properties || !data) { + return 0; + } + + let count = 0; + const properties = schema.properties; + + for (const [key, value] of Object.entries(properties)) { + const currentPath = path ? `${path}.${key}` : key; + const fieldSchema = value as JsonSchema; + const fieldValue = data[key]; + + if (fieldSchema.type === 'object' && fieldSchema.properties) { + if (fieldValue && typeof fieldValue === 'object') { + count += countAnsweredQuestions(fieldSchema, fieldValue, currentPath); + } + } else { + const isAnswered = fieldValue !== undefined && + fieldValue !== null && + fieldValue !== '' && + !(Array.isArray(fieldValue) && fieldValue.length === 0) && + !(typeof fieldValue === 'object' && Object.keys(fieldValue).length === 0); + + if (isAnswered && fieldSchema.format !== 'finalize') { + count++; + } + } + } + + return count; +}; + +/** + * FormProgressBar component that displays form completion progress + */ +const FormProgressBar: React.FC = ({ + currentPage, + totalScreens, + data, + schema, + uischema, + mode = 'screens', + isOnFinalizePage = false +}) => { + const progress = useMemo(() => { + if (mode === 'screens' || mode === 'both') { + if (totalScreens === 0) return 0; + + if (isOnFinalizePage) { + return 100; + } + + const completedScreens = currentPage + 1; + const screenProgress = (completedScreens / totalScreens) * 100; + + if (mode === 'screens') { + return Math.round(screenProgress); + } + + if (schema && data) { + const totalQuestions = countQuestions(schema); + const answeredQuestions = countAnsweredQuestions(schema, data); + const questionProgress = totalQuestions > 0 + ? (answeredQuestions / totalQuestions) * 100 + : 0; + + return Math.round((screenProgress + questionProgress) / 2); + } + + return Math.round(screenProgress); + } else if (mode === 'questions') { + if (!schema || !data) return 0; + + const totalQuestions = countQuestions(schema); + if (totalQuestions === 0) return 0; + + const answeredQuestions = countAnsweredQuestions(schema, data); + return Math.round((answeredQuestions / totalQuestions) * 100); + } + + return 0; + }, [currentPage, totalScreens, data, schema, mode, isOnFinalizePage]); + + if (totalScreens === 0) { + return null; + } + + return ( + + + + + {progress}% + + + + ); +}; + +export default FormProgressBar; + diff --git a/formulus-formplayer/src/SwipeLayoutRenderer.tsx b/formulus-formplayer/src/SwipeLayoutRenderer.tsx index 69e72a856..f4e534a67 100644 --- a/formulus-formplayer/src/SwipeLayoutRenderer.tsx +++ b/formulus-formplayer/src/SwipeLayoutRenderer.tsx @@ -1,10 +1,11 @@ -import React, { useCallback, useState, useEffect } from "react"; +import React, { useCallback, useState, useEffect, useMemo } from "react"; import { JsonFormsDispatch, withJsonFormsControlProps } from "@jsonforms/react"; import { ControlProps, rankWith, uiTypeIs, RankedTester } from "@jsonforms/core"; import { useSwipeable } from "react-swipeable"; import { Button, Box } from "@mui/material"; import { useFormContext } from "./App"; import { draftService } from './DraftService'; +import FormProgressBar from './FormProgressBar'; interface SwipeLayoutProps extends ControlProps { currentPage: number; @@ -48,9 +49,11 @@ const SwipeLayoutRenderer = ({ const isExplicitSwipeLayout = uiType === 'SwipeLayout'; // For SwipeLayout, use elements directly; for Group, wrap the group in an array - const layouts = isExplicitSwipeLayout - ? (uischema as any).elements || [] - : [uischema]; // For Group, treat the entire group as a single page + const layouts = useMemo(() => { + return isExplicitSwipeLayout + ? (uischema as any).elements || [] + : [uischema]; // For Group, treat the entire group as a single page + }, [uischema, isExplicitSwipeLayout]); if (typeof handleChange !== "function") { console.warn("Property 'handleChange' was not supplied to SwipeLayoutRenderer"); @@ -74,8 +77,29 @@ const SwipeLayoutRenderer = ({ onSwipedRight: () => navigateToPage(Math.max(currentPage - 1, 0)), }); + // Calculate total screens including Finalize (so progress reaches 100% only on Finalize) + const totalScreens = useMemo(() => { + // Include all screens including Finalize so progress reaches 100% only when on Finalize page + return layouts.length; + }, [layouts]); + + // Check if we're on the Finalize page + const isOnFinalizePage = useMemo(() => { + return layouts[currentPage]?.type === 'Finalize'; + }, [layouts, currentPage]); + return ( + {/* Progress Bar */} +
{(uischema as any)?.label &&

{(uischema as any).label}

} {layouts.length > 0 && (