From a62f983e2542d1b6291e2418f660cba450c277c2 Mon Sep 17 00:00:00 2001 From: Sowmiya Ravikumar Date: Thu, 25 Jun 2026 16:26:39 +0100 Subject: [PATCH 01/10] Adds conditional rendering of adhoc registration --- .../pages/MenteeRegistrationPage.test.tsx | 121 +++++++++++++++++- src/__tests__/schemas/menteeSchema.test.ts | 114 +++++++++++++++++ .../mentorship/MenteeStep1BasicInfo.tsx | 84 +++++------- .../mentorship/MenteeStep2Skills.tsx | 120 ++++++++--------- src/pages/api/mentors.ts | 8 +- src/pages/mentorship/mentee-registration.tsx | 71 ++++++++-- src/schemas/menteeSchema.ts | 111 +++++++++------- src/utils/mentorshipConstants.ts | 6 + 8 files changed, 463 insertions(+), 172 deletions(-) create mode 100644 src/__tests__/schemas/menteeSchema.test.ts diff --git a/src/__tests__/pages/MenteeRegistrationPage.test.tsx b/src/__tests__/pages/MenteeRegistrationPage.test.tsx index c46e2af..d65e27a 100644 --- a/src/__tests__/pages/MenteeRegistrationPage.test.tsx +++ b/src/__tests__/pages/MenteeRegistrationPage.test.tsx @@ -22,15 +22,18 @@ jest.mock('next/router', () => ({ useRouter: () => ({ push: jest.fn(), pathname: '/' }), })); -// Mutable flag so individual tests can override the registration state +// Mutable flags so individual tests can override registration state let mockIsRegistrationOpen = true; +let mockIsAdhocCycle = false; -// Mock the registration toggle jest.mock('../../utils/mentorshipConstants', () => ({ ...jest.requireActual('../../utils/mentorshipConstants'), get IS_REGISTRATION_OPEN() { return mockIsRegistrationOpen; }, + get IS_ADHOC_CYCLE() { + return mockIsAdhocCycle; + }, })); const renderPage = () => @@ -185,3 +188,117 @@ describe('MenteeRegistrationPage - registration closed', () => { expect(screen.queryByText('Step 1 of 3')).not.toBeInTheDocument(); }); }); + +describe('MenteeRegistrationPage - adhoc cycle', () => { + beforeEach(() => { + mockIsAdhocCycle = true; + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([]), + }); + }); + + afterEach(() => { + mockIsAdhocCycle = false; + jest.resetAllMocks(); + }); + + it('shows ad-hoc breadcrumb label', () => { + renderPage(); + expect( + screen.getByText('Ad-hoc Mentee Registration'), + ).toBeInTheDocument(); + }); + + it('does not render available hours per month field', () => { + renderPage(); + expect(screen.queryByPlaceholderText('e.g. 4')).not.toBeInTheDocument(); + }); + + it('navigates to step 2 without filling available hours', async () => { + renderPage(); + + fireEvent.change(screen.getByPlaceholderText('Jane Doe'), { + target: { value: 'Jane Doe' }, + }); + fireEvent.change(screen.getByPlaceholderText('jane@example.com'), { + target: { value: 'jane@example.com' }, + }); + fireEvent.change(screen.getByPlaceholderText('@jane'), { + target: { value: '@jane' }, + }); + + const countrySelect = screen.getByRole('combobox'); + fireEvent.mouseDown(countrySelect); + const countryOption = await screen.findByRole('option', { + name: /United Kingdom/i, + }); + fireEvent.click(countryOption); + + fireEvent.change(screen.getByPlaceholderText('London'), { + target: { value: 'London' }, + }); + fireEvent.change( + screen.getByPlaceholderText('e.g. Frontend Developer, Student'), + { target: { value: 'Developer' } }, + ); + fireEvent.change(screen.getByPlaceholderText('Acme Corp'), { + target: { value: 'Tech Corp' }, + }); + fireEvent.change( + screen.getByPlaceholderText('https://www.linkedin.com/in/yourprofile'), + { target: { value: 'https://www.linkedin.com/in/janedoe' } }, + ); + + fireEvent.click(screen.getByRole('button', { name: /next/i })); + + await waitFor(() => { + expect(screen.getByText('Step 2 of 3')).toBeInTheDocument(); + }); + }); + + it('shows session focus field on step 2', async () => { + renderPage(); + + fireEvent.change(screen.getByPlaceholderText('Jane Doe'), { + target: { value: 'Jane Doe' }, + }); + fireEvent.change(screen.getByPlaceholderText('jane@example.com'), { + target: { value: 'jane@example.com' }, + }); + fireEvent.change(screen.getByPlaceholderText('@jane'), { + target: { value: '@jane' }, + }); + + const countrySelect = screen.getByRole('combobox'); + fireEvent.mouseDown(countrySelect); + const countryOption = await screen.findByRole('option', { + name: /United Kingdom/i, + }); + fireEvent.click(countryOption); + + fireEvent.change(screen.getByPlaceholderText('London'), { + target: { value: 'London' }, + }); + fireEvent.change( + screen.getByPlaceholderText('e.g. Frontend Developer, Student'), + { target: { value: 'Developer' } }, + ); + fireEvent.change(screen.getByPlaceholderText('Acme Corp'), { + target: { value: 'Tech Corp' }, + }); + fireEvent.change( + screen.getByPlaceholderText('https://www.linkedin.com/in/yourprofile'), + { target: { value: 'https://www.linkedin.com/in/janedoe' } }, + ); + + fireEvent.click(screen.getByRole('button', { name: /next/i })); + + await waitFor(() => { + expect(screen.getByText('Session focus *')).toBeInTheDocument(); + expect( + screen.queryByText('Mentorship goals *'), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/schemas/menteeSchema.test.ts b/src/__tests__/schemas/menteeSchema.test.ts new file mode 100644 index 0000000..5402874 --- /dev/null +++ b/src/__tests__/schemas/menteeSchema.test.ts @@ -0,0 +1,114 @@ +import { + adhocMenteeFormDefaultValues, + menteeFormSchema, +} from '../../schemas/menteeSchema'; + +const validLongTermBase = { + fullName: 'Jane Doe', + position: 'Developer', + email: 'jane@example.com', + slackDisplayName: '@jane', + companyName: 'Acme Corp', + country: { countryCode: 'GB', countryName: 'United Kingdom' }, + city: 'London', + linkedInProfile: 'https://www.linkedin.com/in/janedoe', + pronouns: '', + availableHsMonth: 4, + skills: { + yearsExperience: 2, + areas: [{ technicalArea: 'FRONTEND', proficiencyLevel: 'INTERMEDIATE' }], + languages: [{ language: 'TYPESCRIPT', proficiencyLevel: 'INTERMEDIATE' }], + mentorshipFocus: ['GROW_BEGINNER_TO_MID'], + }, + spokenLanguages: ['English'], + bio: 'A'.repeat(50), + mentorshipType: 'LONG_TERM' as const, + applications: [ + { mentorId: 1, priorityOrder: 1, whyMentor: 'A'.repeat(50) }, + ], +}; + +const validAdhocBase = { + ...validLongTermBase, + mentorshipType: 'AD_HOC' as const, + availableHsMonth: 1, +}; + +describe('menteeFormSchema — long-term', () => { + it('accepts valid long-term data', () => { + const result = menteeFormSchema.safeParse(validLongTermBase); + expect(result.success).toBe(true); + }); + + it('rejects long-term data with no mentorshipFocus', () => { + const data = { + ...validLongTermBase, + skills: { ...validLongTermBase.skills, mentorshipFocus: [] }, + }; + const result = menteeFormSchema.safeParse(data); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((i) => i.path.join('.')); + expect(paths).toContain('skills.mentorshipFocus'); + } + }); +}); + +describe('menteeFormSchema — adhoc', () => { + it('accepts valid adhoc data', () => { + const result = menteeFormSchema.safeParse(validAdhocBase); + expect(result.success).toBe(true); + }); + + it('requires mentorshipFocus for adhoc', () => { + const data = { + ...validAdhocBase, + skills: { ...validAdhocBase.skills, mentorshipFocus: [] }, + }; + const result = menteeFormSchema.safeParse(data); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((i) => i.path.join('.')); + expect(paths).toContain('skills.mentorshipFocus'); + } + }); + + it('accepts adhoc data with mentorshipFocus selected', () => { + const result = menteeFormSchema.safeParse(validAdhocBase); + expect(result.success).toBe(true); + }); +}); + +describe('menteeFormSchema — availableHsMonth', () => { + it('rejects long-term data with availableHsMonth less than 2', () => { + const data = { ...validLongTermBase, availableHsMonth: 1 }; + const result = menteeFormSchema.safeParse(data); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((i) => i.path.join('.')); + expect(paths).toContain('availableHsMonth'); + } + }); + + it('accepts long-term data with availableHsMonth of 2', () => { + const data = { ...validLongTermBase, availableHsMonth: 2 }; + const result = menteeFormSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it('accepts adhoc data with availableHsMonth of 1', () => { + const result = menteeFormSchema.safeParse(validAdhocBase); + expect(result.success).toBe(true); + }); +}); + +describe('adhocMenteeFormDefaultValues', () => { + it('sets mentorshipType to AD_HOC', () => { + expect(adhocMenteeFormDefaultValues.mentorshipType).toBe('AD_HOC'); + }); + + it('sets availableHsMonth to 1', () => { + expect(adhocMenteeFormDefaultValues.availableHsMonth).toBe(1); + }); + +}); diff --git a/src/components/mentorship/MenteeStep1BasicInfo.tsx b/src/components/mentorship/MenteeStep1BasicInfo.tsx index 64a70eb..8eb0bdc 100644 --- a/src/components/mentorship/MenteeStep1BasicInfo.tsx +++ b/src/components/mentorship/MenteeStep1BasicInfo.tsx @@ -1,12 +1,9 @@ import { FormControl, - FormControlLabel, FormHelperText, Grid, InputLabel, MenuItem, - Radio, - RadioGroup, Select, TextField, Typography, @@ -20,7 +17,11 @@ import { COUNTRIES } from '@utils/mentorshipConstants'; import { inputStyle } from './mentorshipStyles'; import StepSection from './StepSection'; -const MenteeStep1BasicInfo = () => { +interface Props { + isAdhoc?: boolean; +} + +const MenteeStep1BasicInfo = ({ isAdhoc = false }: Props) => { const { register, control, @@ -238,60 +239,37 @@ const MenteeStep1BasicInfo = () => { /> - - - Available hours per month * - - ( - field.onChange(parseInt(e.target.value) || 0)} - sx={inputStyle} - /> - )} - /> - - - - + {!isAdhoc && ( + - Mentorship type + Available hours per month * - - } - label="Long-term" - disabled - /> - } - label="Ad-hoc (coming soon)" - disabled - /> - - - Only long-term mentorship is available for this month. - - - + ( + + field.onChange(parseInt(e.target.value) || 0) + } + sx={inputStyle} + /> + )} + /> + + )} ); diff --git a/src/components/mentorship/MenteeStep2Skills.tsx b/src/components/mentorship/MenteeStep2Skills.tsx index d87a139..2b12cae 100644 --- a/src/components/mentorship/MenteeStep2Skills.tsx +++ b/src/components/mentorship/MenteeStep2Skills.tsx @@ -28,19 +28,25 @@ import StepSection from './StepSection'; const EXPERIENCE_OPTIONS = MENTEE_EXPERIENCE_OPTIONS; -const MenteeStep2Skills = () => { +interface Props { + isAdhoc?: boolean; +} + +const MenteeStep2Skills = ({ isAdhoc = false }: Props) => { const { control, register, formState: { errors }, } = useFormContext(); - const skillsErrors = errors.skills as any; - return ( {/* Years of experience */} @@ -92,9 +98,6 @@ const MenteeStep2Skills = () => { groups={TECHNICAL_AREA_GROUPS} proficiencyLevels={PROFICIENCY_LEVELS} /> - {skillsErrors?.areas && ( - {skillsErrors.areas.message} - )} {/* Programming languages with proficiency */} @@ -110,63 +113,62 @@ const MenteeStep2Skills = () => { languages={CODE_LANGUAGES} proficiencyLevels={PROFICIENCY_LEVELS} /> - {skillsErrors?.languages && ( - - {skillsErrors.languages.message} - - )} - {/* Mentorship focus */} + {/* Mentorship goals */} - - Mentorship goals * - - - Select the goals you want to achieve through mentorship. - - ( - - - {MENTORSHIP_FOCUS_AREAS.map((area) => ( - { - if (e.target.checked) { - field.onChange([ - ...(field.value ?? []), - area.value, - ]); - } else { - field.onChange( - (field.value ?? []).filter( - (v: string) => v !== area.value, - ), - ); - } - }} + <> + + Mentorship goals * + + + Select the goals you want to achieve through mentorship. + + ( + + + {MENTORSHIP_FOCUS_AREAS.map((area) => ( + { + if (e.target.checked) { + field.onChange([ + ...(field.value ?? []), + area.value, + ]); + } else { + field.onChange( + (field.value ?? []).filter( + (v: string) => v !== area.value, + ), + ); + } + }} + /> + } + label={area.label} /> - } - label={area.label} - /> - ))} - - {error && ( - {error.message} + ))} + + {error && ( + {error.message} + )} + )} - - )} - /> + /> + {/* Spoken languages */} diff --git a/src/pages/api/mentors.ts b/src/pages/api/mentors.ts index ab1eba1..a07c27a 100644 --- a/src/pages/api/mentors.ts +++ b/src/pages/api/mentors.ts @@ -11,7 +11,8 @@ export default async function handler( return res.status(405).json({ error: `Method ${req.method} Not Allowed` }); } try { - const { keyword, yearsExperience, areas, language, focus } = req.query; + const { keyword, yearsExperience, areas, language, focus, mentorshipTypes } = + req.query; const params = new URLSearchParams(); if (keyword) @@ -28,6 +29,11 @@ export default async function handler( Array.isArray(language) ? language[0] : language, ); if (focus) params.append('focus', Array.isArray(focus) ? focus[0] : focus); + if (mentorshipTypes) + params.append( + 'mentorshipTypes', + Array.isArray(mentorshipTypes) ? mentorshipTypes[0] : mentorshipTypes, + ); const data = await proxyRequest('mentorship/mentors', { method: 'GET', diff --git a/src/pages/mentorship/mentee-registration.tsx b/src/pages/mentorship/mentee-registration.tsx index ac85c4b..7bca0f3 100644 --- a/src/pages/mentorship/mentee-registration.tsx +++ b/src/pages/mentorship/mentee-registration.tsx @@ -17,6 +17,7 @@ import React, { useEffect, useState } from 'react'; import { FormProvider, UseFormReturn, useForm } from 'react-hook-form'; import { + adhocMenteeFormDefaultValues, menteeFormDefaultValues, menteeFormSchema, MenteeFormData, @@ -26,11 +27,13 @@ import MenteeStep2Skills from 'components/mentorship/MenteeStep2Skills'; import MenteeStep3Applications from 'components/mentorship/MenteeStep3Applications'; import { MentorOption } from 'components/mentorship/MentorApplicationCard'; import RegistrationClosed from 'components/mentorship/RegistrationClosed'; -import { IS_REGISTRATION_OPEN } from 'utils/mentorshipConstants'; +import { IS_ADHOC_CYCLE, IS_REGISTRATION_OPEN } from 'utils/mentorshipConstants'; const TOTAL_STEPS = 3; -const validateStep1 = async (formMethods: UseFormReturn) => +const validateStep1LongTerm = async ( + formMethods: UseFormReturn, +) => formMethods.trigger([ 'fullName', 'email', @@ -41,10 +44,37 @@ const validateStep1 = async (formMethods: UseFormReturn) => 'companyName', 'linkedInProfile', 'availableHsMonth', - 'mentorshipType', ]); -const validateStep2 = async (formMethods: UseFormReturn) => +const validateStep1Adhoc = async ( + formMethods: UseFormReturn, +) => + formMethods.trigger([ + 'fullName', + 'email', + 'slackDisplayName', + 'country', + 'city', + 'position', + 'companyName', + 'linkedInProfile', + ]); + +const validateStep2LongTerm = async ( + formMethods: UseFormReturn, +) => + formMethods.trigger([ + 'skills.yearsExperience', + 'skills.areas', + 'skills.languages', + 'skills.mentorshipFocus', + 'spokenLanguages', + 'bio', + ]); + +const validateStep2Adhoc = async ( + formMethods: UseFormReturn, +) => formMethods.trigger([ 'skills.yearsExperience', 'skills.areas', @@ -58,10 +88,11 @@ const MenteeRegistrationPage = () => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); const registrationOpen = IS_REGISTRATION_OPEN; + const isAdhoc = IS_ADHOC_CYCLE; const formMethods = useForm({ resolver: zodResolver(menteeFormSchema), - defaultValues: menteeFormDefaultValues, + defaultValues: isAdhoc ? adhocMenteeFormDefaultValues : menteeFormDefaultValues, mode: 'onChange', }); @@ -72,7 +103,8 @@ const MenteeRegistrationPage = () => { useEffect(() => { if (!registrationOpen) return; - fetch('/api/mentors') + const mentorshipTypeParam = isAdhoc ? 'Ad-Hoc' : 'Long-Term'; + fetch(`/api/mentors?mentorshipTypes=${mentorshipTypeParam}`) .then((res) => res.json()) .then((data) => { const mentorList: MentorOption[] = (data.mentors ?? data ?? []).map( @@ -91,8 +123,14 @@ const MenteeRegistrationPage = () => { const handleNext = async () => { let isValid; - if (activeStep === 1) isValid = await validateStep1(formMethods); - else if (activeStep === 2) isValid = await validateStep2(formMethods); + if (activeStep === 1) + isValid = isAdhoc + ? await validateStep1Adhoc(formMethods) + : await validateStep1LongTerm(formMethods); + else if (activeStep === 2) + isValid = isAdhoc + ? await validateStep2Adhoc(formMethods) + : await validateStep2LongTerm(formMethods); else isValid = true; if (isValid && activeStep < TOTAL_STEPS) { @@ -189,7 +227,9 @@ const MenteeRegistrationPage = () => { Mentorship - Mentee Registration + + {isAdhoc ? 'Ad-hoc Mentee Registration' : 'Mentee Registration'} + @@ -260,8 +300,9 @@ const MenteeRegistrationPage = () => { color="text.secondary" sx={{ mb: 3 }} > - Thank you for applying to our mentorship programme. We will - review your application and get back to you soon. + {isAdhoc + ? 'Thank you for applying to our ad-hoc mentorship programme. We will review your application and get back to you soon.' + : 'Thank you for applying to our mentorship programme. We will review your application and get back to you soon.'}