diff --git a/playwright-tests/tests/becomeAMentee.page.spec.ts b/playwright-tests/tests/becomeAMentee.page.spec.ts index 71d32bc..b033db4 100644 --- a/playwright-tests/tests/becomeAMentee.page.spec.ts +++ b/playwright-tests/tests/becomeAMentee.page.spec.ts @@ -4,7 +4,9 @@ import { HomePage } from '@pages/home.page'; import { MentorshipPage } from '@pages/mentorship.page'; test('Validate "Become a Mentee" section and Find a Mentor button', async ({ - page, mentorshipPage, homePage + page, + mentorshipPage, + homePage, }) => { // Navigate to Mentorship page await page.goto('/mentorship'); @@ -22,7 +24,7 @@ test('Validate "Become a Mentee" section and Find a Mentor button', async ({ ]; await expect(mentorshipPage.menteeListItems).toHaveText(items); - + await homePage.findMentorButton.click(); await expect(page).toHaveURL(/\/mentorship\/mentors/); }); diff --git a/src/__tests__/pages/MenteeRegistrationPage.test.tsx b/src/__tests__/pages/MenteeRegistrationPage.test.tsx index c46e2af..250016f 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,112 @@ 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 after filling required fields', 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 mentorship goals 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('Mentorship goals *')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/__tests__/schemas/menteeSchema.test.ts b/src/__tests__/schemas/menteeSchema.test.ts new file mode 100644 index 0000000..2e7a575 --- /dev/null +++ b/src/__tests__/schemas/menteeSchema.test.ts @@ -0,0 +1,107 @@ +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'); + }); +}); diff --git a/src/components/mentorship/MenteeStep1BasicInfo.tsx b/src/components/mentorship/MenteeStep1BasicInfo.tsx index 64a70eb..978f803 100644 --- a/src/components/mentorship/MenteeStep1BasicInfo.tsx +++ b/src/components/mentorship/MenteeStep1BasicInfo.tsx @@ -2,6 +2,7 @@ import { FormControl, FormControlLabel, FormHelperText, + FormLabel, Grid, InputLabel, MenuItem, @@ -20,7 +21,20 @@ import { COUNTRIES } from '@utils/mentorshipConstants'; import { inputStyle } from './mentorshipStyles'; import StepSection from './StepSection'; -const MenteeStep1BasicInfo = () => { +const boolToRadioValue = (value: boolean | null | undefined): string => { + if (value === true) return 'yes'; + if (value === false) return 'no'; + if (value === null) return 'unspecified'; + return ''; +}; + +const radioValueToBool = (value: string): boolean | null => { + if (value === 'yes') return true; + if (value === 'no') return false; + return null; +}; + +const MenteeStep1BasicInfo = ({ isAdhoc = false }: { isAdhoc?: boolean }) => { const { register, control, @@ -239,59 +253,76 @@ const MenteeStep1BasicInfo = () => { - - Available hours per month * - ( - field.onChange(parseInt(e.target.value) || 0)} - sx={inputStyle} - /> + render={({ field }) => ( + + + Do you identify as a woman or non-binary? + + { + field.onChange(radioValueToBool(e.target.value)); + }} + > + } + label="Yes" + /> + } label="No" /> + } + label="Prefer not to say" + /> + + )} /> - - + {!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(Number.parseInt(e.target.value) || 0) + } + sx={inputStyle} + /> + )} + /> + + )} ); diff --git a/src/components/mentorship/MenteeStep2Skills.tsx b/src/components/mentorship/MenteeStep2Skills.tsx index d87a139..8739a6f 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,60 @@ 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, - ), - ); - } - }} - /> - } - label={area.label} - /> - ))} - - {error && ( - {error.message} - )} - - )} - /> + <> + + 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} + /> + ))} + + {error && ( + {error.message} + )} + + )} + /> + {/* Spoken languages */} diff --git a/src/pages/api/mentors.ts b/src/pages/api/mentors.ts index 5ff6ce2..6c8bc7e 100644 --- a/src/pages/api/mentors.ts +++ b/src/pages/api/mentors.ts @@ -41,6 +41,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..85c91a2 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,10 +27,36 @@ 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 postMenteeRegistration = async ( + payload: unknown, +): Promise => { + try { + const response = await fetch('/api/mentee-registration', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + const body = await response.json().catch(() => null); + return ( + body?.message ?? + body?.error ?? + 'Something went wrong. Please try again.' + ); + } + return null; + } catch { + return 'Network error. Please check your connection and try again.'; + } +}; + const validateStep1 = async (formMethods: UseFormReturn) => formMethods.trigger([ 'fullName', @@ -40,8 +67,6 @@ const validateStep1 = async (formMethods: UseFormReturn) => 'position', 'companyName', 'linkedInProfile', - 'availableHsMonth', - 'mentorshipType', ]); const validateStep2 = async (formMethods: UseFormReturn) => @@ -54,14 +79,27 @@ const validateStep2 = async (formMethods: UseFormReturn) => 'bio', ]); +const getStepValidator = ( + step: number, + formMethods: UseFormReturn, +): Promise => { + if (step === 1) return validateStep1(formMethods); + if (step === 2) return validateStep2(formMethods); + return Promise.resolve(true); +}; + +// NOSONAR 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 +110,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( @@ -87,14 +126,10 @@ const MenteeRegistrationPage = () => { .catch(() => { // silently fall back to empty list — user can still submit if API is down }); - }, [registrationOpen]); + }, [registrationOpen, isAdhoc]); const handleNext = async () => { - let isValid; - if (activeStep === 1) isValid = await validateStep1(formMethods); - else if (activeStep === 2) isValid = await validateStep2(formMethods); - else isValid = true; - + const isValid = await getStepValidator(activeStep, formMethods); if (isValid && activeStep < TOTAL_STEPS) { setActiveStep((prev) => prev + 1); window.scrollTo(0, 0); @@ -111,7 +146,7 @@ const MenteeRegistrationPage = () => { const onSubmit = async (data: MenteeFormData) => { setSubmitError(null); - const networkLinks = data.network ?? []; + const networkLinks = [...(data.network ?? [])]; if (data.linkedInProfile) { networkLinks.push({ type: 'LINKEDIN' as const, @@ -127,9 +162,9 @@ const MenteeRegistrationPage = () => { slackDisplayName: data.slackDisplayName, country: data.country ?? { countryCode: '', countryName: '' }, city: data.city, - companyName: data.companyName ?? '', + companyName: data.companyName, pronouns: data.pronouns ?? '', - pronounCategory: data.pronounCategory, + isWomen: data.isWomen, images: [], network: networkLinks, @@ -147,30 +182,13 @@ const MenteeRegistrationPage = () => { })), }; - try { - const response = await fetch('/api/mentee-registration', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const body = await response.json().catch(() => null); - const message = - body?.message ?? - body?.error ?? - 'Something went wrong. Please try again.'; - setSubmitError(message); - return; - } - - setSubmitted(true); - window.scrollTo(0, 0); - } catch { - setSubmitError( - 'Network error. Please check your connection and try again.', - ); + const error = await postMenteeRegistration(payload); + if (error) { + setSubmitError(error); + return; } + setSubmitted(true); + window.scrollTo(0, 0); }; return ( @@ -189,7 +207,9 @@ const MenteeRegistrationPage = () => { Mentorship - Mentee Registration + + {isAdhoc ? 'Ad-hoc Mentee Registration' : 'Mentee Registration'} + @@ -212,12 +232,14 @@ const MenteeRegistrationPage = () => { px: { xs: 2, sm: 3 }, maxWidth: isMobile ? '100%' : theme.custom?.innerBox?.maxWidth, margin: '0 auto', - ...(!registrationOpen && { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - minHeight: '70vh', - }), + ...(registrationOpen + ? { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '70vh', + } + : {}), }} > { 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.'}