Skip to content

Commit

Permalink
Form Validation Errors (#512)
Browse files Browse the repository at this point in the history
  • Loading branch information
aidantrabs authored Feb 17, 2025
2 parents d70799c + c18501e commit 33cf03c
Show file tree
Hide file tree
Showing 9 changed files with 680 additions and 90 deletions.
73 changes: 73 additions & 0 deletions frontend/src/components/AutoSaveIndicator/AutosaveIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React, { useEffect, useState } from 'react';
import { cva } from 'class-variance-authority';

const indicatorStyles = cva(
'fixed left-0 right-0 transition-all duration-300 flex items-center justify-center py-1 text-sm font-medium z-40 border-b',
{
variants: {
status: {
idle: 'bg-gray-50 text-gray-600',
saving: 'bg-blue-50 text-blue-700',
success: 'bg-green-50 text-green-700',
error: 'bg-red-50 text-red-700'
}
},
defaultVariants: {
status: 'idle'
}
}
);

export interface AutosaveIndicatorProps {
status: 'idle' | 'saving' | 'success' | 'error';
message?: string;
}

export const AutosaveIndicator: React.FC<AutosaveIndicatorProps> = ({
status,
message
}) => {
const [showSuccess, setShowSuccess] = useState(false);

useEffect(() => {
let timeout: NodeJS.Timeout;

if (status === 'success') {
setShowSuccess(true);
timeout = setTimeout(() => {
setShowSuccess(false);
}, 2000);
}

return () => {
if (timeout) clearTimeout(timeout);
};
}, [status]);

const indicatorStatus =
status === 'saving' ? 'saving' :
status === 'error' ? 'error' :
showSuccess ? 'success' : 'idle';

const defaultMessages = {
idle: 'Your answers will be autosaved as you complete your application',
saving: 'Autosaving...',
success: 'All changes saved',
error: 'Failed to save changes'
};

const displayMessage = message || defaultMessages[indicatorStatus];

return (
<div style={{ top: '96px' }} className={indicatorStyles({ status: indicatorStatus })}>
<div className="flex items-center gap-2">
{status === 'saving' && (
<div className="w-4 h-4 relative">
<div className="absolute inset-0 border-2 border-blue-700 border-solid rounded-full border-r-transparent animate-spin" />
</div>
)}
<span>{displayMessage}</span>
</div>
</div>
);
};
2 changes: 2 additions & 0 deletions frontend/src/components/AutoSaveIndicator/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { AutosaveIndicator } from './AutoSaveIndicator';
export type { AutosaveIndicatorProps } from './AutoSaveIndicator';
121 changes: 121 additions & 0 deletions frontend/src/components/ProjectError/ProjectError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import React, { useState } from 'react';
import { MdKeyboardArrowDown, MdKeyboardArrowUp } from 'react-icons/md';
import { sanitizeHtmlId } from '@/utils/html';

export interface ValidationError {
section: string;
subsection: string;
questionText: string;
inputType: string;
required: boolean;
value: any;
reason: string;
}

interface ErrorsBySection {
[section: string]: {
count: number;
errors: ValidationError[];
};
}

export interface ProjectErrorProps {
errors: ValidationError[];
onErrorClick: (section: string, subsectionId: string) => void;
}

export const ProjectError: React.FC<ProjectErrorProps> = ({ errors, onErrorClick }) => {
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());

if (errors.length === 0) return null;

const errorsBySection: ErrorsBySection = errors.reduce((acc, error) => {
if (!acc[error.section]) {
acc[error.section] = {
count: 0,
errors: []
};
}

acc[error.section].count += 1;
acc[error.section].errors.push(error);

return acc;
}, {} as ErrorsBySection);

const toggleSection = (section: string) => {
setExpandedSections(prev => {
const newSet = new Set(prev);

if (newSet.has(section)) {
newSet.delete(section);
} else {
newSet.add(section);
}

return newSet;
});
};

const handleErrorClick = (error: ValidationError, e: React.MouseEvent) => {
e.preventDefault();
onErrorClick(error.section, sanitizeHtmlId(error.subsection));
};

return (
<div className="fixed top-32 right-8 w-80 bg-white border border-dashed border-red-600 rounded-lg overflow-hidden">
<div className="bg-red-50 p-4 border-b border-red-100">
<div className="text-red-600 text-lg font-semibold">
Oops! You're missing information
</div>
</div>

<div className="p-4 max-h-[calc(50vh-100px)] overflow-y-auto">
{Object.entries(errorsBySection).map(([section, { count, errors }]) => (
<div key={section} className="mb-4 last:mb-0">
<button
onClick={() => toggleSection(section)}
className="w-full flex items-center justify-between text-left mb-2 group"
>
<div>
<h3 className="font-medium text-gray-900">{section}</h3>
<p className="text-gray-600 text-sm">
{count} unfilled required field{count !== 1 ? 's' : ''}
</p>
</div>
{expandedSections.has(section) ? (
<MdKeyboardArrowUp className="h-5 w-5 text-gray-500 group-hover:text-gray-700" />
) : (
<MdKeyboardArrowDown className="h-5 w-5 text-gray-500 group-hover:text-gray-700" />
)}
</button>

{expandedSections.has(section) && (
<div className="pl-4 space-y-2">
{errors.map((error, idx) => (
<div key={idx} className="border-l-2 border-red-200 pl-3">
<button
onClick={(e) => handleErrorClick(error, e)}
className="block w-full text-left hover:bg-red-50 p-2 rounded transition-colors"
>
<p className="text-sm font-medium text-gray-900">
{error.questionText}
</p>
<p className="text-xs text-gray-500 mt-1">
{error.reason} in {error.subsection}
</p>
</button>
</div>
))}
</div>
)}
</div>
))}

<p className="text-sm text-gray-600 mt-4">
Please review these sections before submitting. Click on each error to go to the relevant question.
</p>
</div>
</div>
);
};
2 changes: 2 additions & 0 deletions frontend/src/components/ProjectError/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ProjectError } from './ProjectError';
export type { ProjectErrorProps, ValidationError } from './ProjectError';
116 changes: 101 additions & 15 deletions frontend/src/components/QuestionInputs/QuestionInputs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,47 @@ import {
import { Question } from '@/config/forms';
import { FormField } from '@/types';
import { FC } from 'react';
import { cva } from 'class-variance-authority';

const legendStyles = cva("block text-md font-normal", {
variants: {
hasError: {
true: "text-red-600",
false: "text-gray-900",
}
},
defaultVariants: {
hasError: false,
}
});

const requiredIndicatorStyles = cva("ml-1", {
variants: {
hasError: {
true: "text-red-500",
false: "text-gray-500",
}
},
defaultVariants: {
hasError: false,
}
});

const requiredTextStyles = cva("text-sm", {
variants: {
hasError: {
true: "text-red-500",
false: "text-gray-500"
}
},
defaultVariants: {
hasError: false
}
});

const fieldsetStyles = cva("space-y-4");

const headerContainerStyles = cva("flex justify-between items-center mb-1");

interface QuestionInputsProps {
question: Question;
Expand All @@ -29,7 +70,51 @@ export const QuestionInputs: FC<QuestionInputsProps> = ({
onChange,
fileUploadProps,
}) => {
const hasInvalidField = question.inputFields.some((field) => field.invalid);
const isQuestionRequired = question.inputFields.some((field) => field.required);

const getErrorMessage = (field: FormField): string => {
if (!field.invalid) return '';

if (!field.value.value) {
switch (field.type) {
case 'textinput':
return 'Please enter a value';
case 'textarea':
return 'Please provide a description';
case 'multiselect':
return 'Please select at least one option';
case 'select':
return 'Please select an option';
case 'date':
return 'Please select a date';
default:
return 'This field is required';
}
}

if (field.validations) {
switch (field.type) {
case 'textinput':
return 'Please enter a valid value';
case 'textarea':
return 'The text provided is not valid';
case 'multiselect':
case 'select':
return 'One or more selected options are not valid';
case 'date':
return 'Please select a valid date';
default:
return 'The provided value is not valid';
}
}

return 'This field is required';
};

const renderInput = (field: FormField) => {
const errorMessage = getErrorMessage(field);

switch (field.type) {
case 'textinput':
return (
Expand All @@ -39,11 +124,7 @@ export const QuestionInputs: FC<QuestionInputsProps> = ({
onChange={(e) =>
onChange(question.id, field.key, e.target.value)
}
error={
field.invalid
? 'This input has invalid content'
: ''
}
error={errorMessage}
required={field.required}
disabled={field.disabled}
/>
Expand All @@ -59,11 +140,7 @@ export const QuestionInputs: FC<QuestionInputsProps> = ({
}
required={field.required}
rows={field.rows || 4}
error={
field.invalid
? 'This input has invalid content'
: ''
}
error={errorMessage}
disabled={field.disabled}
/>
);
Expand Down Expand Up @@ -105,6 +182,7 @@ export const QuestionInputs: FC<QuestionInputsProps> = ({
)
}
multiple={field.type === 'multiselect'}
error={errorMessage}
/>
);

Expand All @@ -119,6 +197,7 @@ export const QuestionInputs: FC<QuestionInputsProps> = ({
value={field.value.value}
onChange={(v) => onChange(question.id, field.key, v)}
disabled={field.disabled}
error={errorMessage}
/>
);

Expand All @@ -128,13 +207,20 @@ export const QuestionInputs: FC<QuestionInputsProps> = ({
};

return (
<fieldset>
<div className="flex justify-between items-center mb-1">
<legend className="block text-md font-normal">
<fieldset className={fieldsetStyles()}>
<div className={headerContainerStyles()}>
<legend className={legendStyles({ hasError: hasInvalidField })}>
{question.question}
{isQuestionRequired && (
<span className={requiredIndicatorStyles({ hasError: hasInvalidField })}>
*
</span>
)}
</legend>
{question.required && (
<span className="text-sm text-gray-500">Required</span>
{isQuestionRequired && (
<span className={requiredTextStyles({ hasError: hasInvalidField })}>
Required
</span>
)}
</div>
<div className="space-y-4">
Expand Down
Loading

0 comments on commit 33cf03c

Please sign in to comment.