From e857e04d21dfe4b66535a885b8a8f8fd9de845d3 Mon Sep 17 00:00:00 2001 From: Bahati308 Date: Tue, 9 Dec 2025 12:17:31 +0300 Subject: [PATCH] feat(formplayer): Add dynamic progress bar to form screens Add FormProgressBar component using MUI LinearProgress that displays at the top of each form screen. Progress updates dynamically as users navigate and reaches 100% only on the Finalize page. Fixes #8 --- formulus-formplayer/src/FormProgressBar.tsx | 205 ++++++++++++++++++ .../src/SwipeLayoutRenderer.tsx | 32 ++- 2 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 formulus-formplayer/src/FormProgressBar.tsx 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 && (