From fffdfcd0bdf6da2d43840f13d70dbc9e234792f6 Mon Sep 17 00:00:00 2001 From: Danitello123 Date: Sat, 30 May 2026 19:48:08 +0100 Subject: [PATCH] Implement Certification Program support for input fields and add certificate generation page --- src/app/certificates/__tests__/page.test.tsx | 40 ++++++ src/app/certificates/page.tsx | 119 ++++++++++++++++++ src/components/forms/FormInput.tsx | 16 ++- .../forms/__tests__/FormInput.test.tsx | 18 +++ 4 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 src/app/certificates/__tests__/page.test.tsx create mode 100644 src/app/certificates/page.tsx diff --git a/src/app/certificates/__tests__/page.test.tsx b/src/app/certificates/__tests__/page.test.tsx new file mode 100644 index 00000000..b47997a1 --- /dev/null +++ b/src/app/certificates/__tests__/page.test.tsx @@ -0,0 +1,40 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, afterEach } from 'vitest'; +import CertificateGenerationPage from '../page'; +import { apiClient } from '@/lib/api'; + +vi.mock('@/lib/api', () => ({ + apiClient: { + post: vi.fn(), + }, +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('CertificateGenerationPage', () => { + it('submits certificate generation data and displays a success message', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ certificateId: 'cert-123' }); + + render(); + + fireEvent.change(screen.getByLabelText(/Course ID/i), { + target: { value: '123e4567-e89b-12d3-a456-426614174000' }, + }); + fireEvent.change(screen.getByLabelText(/Student Name/i), { + target: { value: 'Jane Doe' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i })); + + await waitFor(() => { + expect(screen.getByText(/Certificate generated successfully/i)).toBeInTheDocument(); + }); + + expect(apiClient.post).toHaveBeenCalledWith('/api/certificates/generate', { + courseId: '123e4567-e89b-12d3-a456-426614174000', + name: 'Jane Doe', + }); + }); +}); diff --git a/src/app/certificates/page.tsx b/src/app/certificates/page.tsx new file mode 100644 index 00000000..c25ae9d6 --- /dev/null +++ b/src/app/certificates/page.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useState } from 'react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { motion } from 'framer-motion'; +import { CertificateInputSchema, type CertificateInput } from '@/schemas/certificate.schema'; +import { apiClient } from '@/lib/api'; +import { FormInput } from '@/components/forms/FormInput'; +import { FieldError, FormError } from '@/components/forms/FormError'; +import { SubmitButton } from '@/components/forms/SubmitButton'; + +export default function CertificateGenerationPage() { + const [apiError, setApiError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const methods = useForm({ + resolver: zodResolver(CertificateInputSchema), + mode: 'onTouched', + }); + + const { + handleSubmit, + formState: { errors, isSubmitting }, + reset, + } = methods; + + const onSubmit = async (data: CertificateInput) => { + setApiError(null); + setSuccessMessage(null); + + try { + const result = await apiClient.post<{ certificateId: string }>('/api/certificates/generate', data); + setSuccessMessage(`Certificate generated successfully. ID: ${result.certificateId}`); + reset(); + } catch (error) { + setApiError( + error instanceof Error + ? error.message + : 'Unable to generate certificate. Please try again.', + ); + } + }; + + return ( +
+
+ +
+

+ Certification Program +

+

+ Generate your Course Certificate +

+

+ Complete the form below to request a certificate for a completed course. Your input fields are + validated, accessible, and connected to the Certification Program workflow. +

+
+ + + + + + + + + {successMessage && ( + + {successMessage} + + )} + +
+ + Generate certificate + +
+
+
+
+
+
+ ); +} diff --git a/src/components/forms/FormInput.tsx b/src/components/forms/FormInput.tsx index 0a371dc1..cb3c84c2 100644 --- a/src/components/forms/FormInput.tsx +++ b/src/components/forms/FormInput.tsx @@ -13,6 +13,7 @@ interface FormInputProps children?: React.ReactNode; // For select options rows?: number; // Explicitly add rows for textarea helperText?: React.ReactNode; + certificationProgram?: string; } /** @@ -29,6 +30,7 @@ export const FormInput: React.FC = ({ id, required, helperText, + certificationProgram, 'aria-describedby': ariaDescribedBy, ...props }) => { @@ -42,12 +44,14 @@ export const FormInput: React.FC = ({ const isError = !!error; const inputId = id ?? `${name}-${generatedId}`; const helperTextId = helperText ? `${inputId}-helper` : undefined; + const certificationProgramId = certificationProgram ? `${inputId}-certification-program` : undefined; const errorId = isError ? `${inputId}-error` : undefined; const describedBy = - [ariaDescribedBy, helperTextId, errorId].filter(Boolean).join(' ') || undefined; + [ariaDescribedBy, helperTextId, certificationProgramId, errorId].filter(Boolean).join(' ') || undefined; + const paddingLeftClass = Icon ? 'pl-10' : 'pl-4'; const baseStyles = ` - w-full pl-${Icon ? '10' : '4'} pr-4 py-2.5 + w-full ${paddingLeftClass} pr-4 py-2.5 bg-gray-50 border rounded-xl focus:bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200 @@ -63,6 +67,14 @@ export const FormInput: React.FC = ({ > {label} + {certificationProgram && ( +

+ Certification program: {certificationProgram} +

+ )}
{Icon && (
{ expect(input).toHaveAttribute('aria-invalid', 'false'); }); + it('renders certification program metadata and includes it in accessible description', () => { + renderWithForm( + , + ); + + expect(screen.getByText('Certification program: Certificate of completion')).toBeInTheDocument(); + + const input = screen.getByRole('textbox', { name: 'Student Name' }); + expect(input).toHaveAccessibleDescription( + 'Enter the full name to display on the certificate. Certification program: Certificate of completion', + ); + }); + it('announces validation errors through aria-describedby and alert role', async () => { renderWithForm(