Skip to content
Merged
Show file tree
Hide file tree
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
205 changes: 205 additions & 0 deletions formulus-formplayer/src/FormProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import React, { useMemo } from 'react';
import { Box, LinearProgress, Typography } from '@mui/material';

type JsonSchema = {
type?: string | string[];
properties?: Record<string, any>;
[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<string, any>;
/**
* 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<string, any>,
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<FormProgressBarProps> = ({
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 (
<Box
sx={{
width: '100%',
mb: 2,
px: { xs: 1, sm: 2 }
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<LinearProgress
variant="determinate"
value={progress}
sx={{
flexGrow: 1,
height: 8,
borderRadius: 4,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
'& .MuiLinearProgress-bar': {
borderRadius: 4,
transition: 'transform 0.4s ease-in-out'
}
}}
/>
<Typography
variant="caption"
sx={{
minWidth: '45px',
textAlign: 'right',
color: 'text.secondary',
fontWeight: 500
}}
>
{progress}%
</Typography>
</Box>
</Box>
);
};

export default FormProgressBar;

32 changes: 28 additions & 4 deletions formulus-formplayer/src/SwipeLayoutRenderer.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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'<function> was not supplied to SwipeLayoutRenderer");
Expand All @@ -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 (
<Box sx={{ position: 'relative', height: '100%' }}>
{/* Progress Bar */}
<FormProgressBar
currentPage={currentPage}
totalScreens={totalScreens}
data={data}
schema={schema}
uischema={uischema}
mode="screens"
isOnFinalizePage={isOnFinalizePage}
/>
<div {...handlers} className="swipelayout_screen">
{(uischema as any)?.label && <h1>{(uischema as any).label}</h1>}
{layouts.length > 0 && (
Expand Down