diff --git a/src/api/client.test.ts b/src/api/client.test.ts new file mode 100644 index 00000000..3c899f06 --- /dev/null +++ b/src/api/client.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ApiError, apiClient } from "./client"; + +describe("apiClient expected errors", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("does not log an expected business-error status", async () => { + vi.stubEnv("NODE_ENV", "development"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + hasError: true, + statusCode: 400, + message: { general: ["No mentor request found for your account."] }, + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => { + // Expected application states should not appear as console failures. + }); + + await expect( + apiClient.get("/api/v1/dashboard/mentor/status/", undefined, { + expectedErrorStatuses: [400], + }), + ).rejects.toBeInstanceOf(ApiError); + + expect(consoleError).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/client.ts b/src/api/client.ts index 382bf484..ad6b579e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -66,6 +66,8 @@ interface RequestOptions { isFormData?: boolean; /** When true, a 403 throws ApiError instead of the auth flow. */ skipAuthRedirectOn403?: boolean; + /** Statuses that represent an expected UI state and should not be logged. */ + expectedErrorStatuses?: readonly number[]; /** When true, a Zod parse failure throws instead of returning raw. */ strictSchema?: boolean; } @@ -75,6 +77,7 @@ type ClientOptions = { responseType?: "json" | "blob"; isFormData?: boolean; skipAuthRedirectOn403?: boolean; + expectedErrorStatuses?: readonly number[]; strictSchema?: boolean; }; @@ -208,7 +211,10 @@ async function request( extractDjangoMessage(rawData) ?? "Something went wrong. Please try again."; const error = new ApiError(res.status, backendMsg, rawData); - if (process.env.NODE_ENV === "development") { + if ( + process.env.NODE_ENV === "development" && + !options.expectedErrorStatuses?.includes(res.status) + ) { console.error( `[API Client] Business error: [Status ${res.status}] ${endpoint}\nMessage: ${backendMsg}`, rawData, @@ -222,7 +228,10 @@ async function request( extractDjangoMessage(rawData) ?? "Something went wrong. Please try again."; const error = new ApiError(res.status, backendMsg, rawData); - if (process.env.NODE_ENV === "development") { + if ( + process.env.NODE_ENV === "development" && + !options.expectedErrorStatuses?.includes(res.status) + ) { console.error( `[API Client] HTTP error: [Status ${res.status}] ${endpoint}\nMessage: ${backendMsg}`, rawData, diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index b9af89d9..1e308ecb 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -355,6 +355,10 @@ export const endpoints = { // ── Activity feed ──────────────────────────────────────────────────── /** GET - Merged timeline: sessions created + task submissions appraised */ activity: "/api/v1/dashboard/mentor/activity/", + + // ── Company affiliation change ──────────────────────────────────────── + /** POST - Request a company affiliation change (pending admin approval) */ + changeCompany: "/api/v1/dashboard/mentor/change-company/", }, // ============================================ diff --git a/src/components/ui/multi-select.tsx b/src/components/ui/multi-select.tsx index 838881a2..8a9f95c2 100644 --- a/src/components/ui/multi-select.tsx +++ b/src/components/ui/multi-select.tsx @@ -2,6 +2,7 @@ import { Check, ChevronDown, X } from "lucide-react"; import * as React from "react"; +import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { Badge } from "./badge"; @@ -17,6 +18,8 @@ interface MultiSelectProps { placeholder?: string; disabled?: boolean; className?: string; + maxSelections?: number; + minSelections?: number; } export function MultiSelect({ @@ -26,6 +29,8 @@ export function MultiSelect({ placeholder = "Select options...", disabled = false, className, + maxSelections, + minSelections, }: MultiSelectProps) { const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(""); @@ -42,6 +47,14 @@ export function MultiSelect({ if (value.includes(optValue)) { onChange(value.filter((v) => v !== optValue)); } else { + if (maxSelections !== undefined && value.length >= maxSelections) { + toast.error( + `You can select a maximum of ${maxSelections} Interest Group${ + maxSelections === 1 ? "" : "s" + }.`, + ); + return; + } onChange([...value, optValue]); } }; @@ -91,19 +104,26 @@ export function MultiSelect({ {selectedLabels.length === 0 ? ( {placeholder} ) : ( - selectedLabels.map((o) => ( - - {o.label} - - - )) + <> + {selectedLabels.map((o) => ( + + {o.label} + + + ))} + {maxSelections !== undefined && ( + + {value.length}/{maxSelections} + + )} + )} { const selected = value.includes(o.value); + const atMax = + maxSelections !== undefined && + value.length >= maxSelections && + !selected; return (
@@ -116,15 +101,6 @@ export function MenteesPage({ title = "Mentees" }: { title?: string } = {}) { onChange={(e) => setSearch(e.target.value)} />
- {/* Desktop Join Session Button */} - @@ -229,12 +205,6 @@ export function MenteesPage({ title = "Mentees" }: { title?: string } = {}) { sessionId={feedbackDialog.sessionId} sessionTitle={feedbackDialog.sessionTitle} /> - - setJoinDialog((prev) => ({ ...prev, open }))} - defaultSessionId={joinDialog.sessionId} - /> ); } diff --git a/src/features/mentor/onboarding/api/onboarding.api.ts b/src/features/mentor/onboarding/api/onboarding.api.ts index 229ed910..5e66ecc5 100644 --- a/src/features/mentor/onboarding/api/onboarding.api.ts +++ b/src/features/mentor/onboarding/api/onboarding.api.ts @@ -3,6 +3,7 @@ import { endpoints } from "@/api/endpoints"; import type { MentorProfileWrite } from "../schemas"; import { type MentorApplication, + MentorApplicationMutationResponseSchema, MentorApplicationResponseSchema, type MentorStatusData, MentorStatusResponseSchema, @@ -14,7 +15,12 @@ export async function getMentorApplicationStatus(): Promise { const res = await apiClient.get( endpoints.mentor.status, MentorStatusResponseSchema, - { skipAuthRedirectOn403: true }, + { + skipAuthRedirectOn403: true, + // The backend represents an absent application as 400. The onboarding + // hook intentionally maps that response to the "not applied" state. + expectedErrorStatuses: [400], + }, ); return res.response; } @@ -23,28 +29,26 @@ export async function getMentorApplicationStatus(): Promise { // Submit a new mentor application. export async function submitMentorApplication( data: MentorProfileWrite, -): Promise { - const res = await apiClient.post( +): Promise { + await apiClient.post( endpoints.mentor.register, data, - MentorApplicationResponseSchema, + MentorApplicationMutationResponseSchema, { skipAuthRedirectOn403: true }, ); - return res.response; } // ─── PATCH /register/ ───────────────────────────────────────────────────────── // Update a PENDING or REJECTED application (re-submits rejected ones as PENDING). export async function updateMentorApplication( data: Partial, -): Promise { - const res = await apiClient.patch( +): Promise { + await apiClient.patch( endpoints.mentor.register, data, - MentorApplicationResponseSchema, + MentorApplicationMutationResponseSchema, { skipAuthRedirectOn403: true }, ); - return res.response; } // ─── GET /profile/ ──────────────────────────────────────────────────────────── @@ -71,3 +75,17 @@ export async function updateMentorProfile( ); return res.response; } + +// ── POST /change-company/ ───────────────────────────────────────────────────── +// Submit a company affiliation change request (pending admin approval). +export async function requestMentorCompanyChange(payload: { + company_id: string; + reason: string; +}): Promise { + await apiClient.post( + endpoints.mentor.changeCompany, + payload, + MentorApplicationMutationResponseSchema, + { skipAuthRedirectOn403: true }, + ); +} diff --git a/src/features/mentor/onboarding/components/mentor-onboarding-form.tsx b/src/features/mentor/onboarding/components/mentor-onboarding-form.tsx index 6cdf05f9..2e1ed025 100644 --- a/src/features/mentor/onboarding/components/mentor-onboarding-form.tsx +++ b/src/features/mentor/onboarding/components/mentor-onboarding-form.tsx @@ -1,8 +1,9 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; +import { z } from "zod"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -21,9 +22,18 @@ import { FormMessage, } from "@/components/ui/form"; import { MultiSelect } from "@/components/ui/multi-select"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TagInput } from "@/components/ui/tag-input"; import { Textarea } from "@/components/ui/textarea"; import { useInterestGroupsList } from "@/features/home/hooks"; +import { useCompanies } from "@/features/onboarding/hooks"; import { useOnboardingDraftStore } from "../hooks/use-draft-store"; import { useSubmitMentorApplication, @@ -32,18 +42,137 @@ import { import type { MentorApplication } from "../schemas"; import { OnboardingFormSchema, type OnboardingFormValues } from "../schemas"; +// ── Per-tab Zod schemas ─────────────────────────────────────────────────────── +const IgFormSchema = OnboardingFormSchema; + +// Company Mentor: `org` is required (it's the company/organisation UUID) +const CompanyFormSchema = OnboardingFormSchema.extend({ + org: z.string().min(1, "Please select a Company Affiliation"), +}); + +type MentorType = "ig" | "company"; + interface MentorOnboardingFormProps { existing?: MentorApplication; isEdit?: boolean; isReapply?: boolean; } +// ── SharedFields ────────────────────────────────────────────────────────────── +// Defined at MODULE SCOPE (not inside MentorOnboardingForm). +// Keeping it outside prevents React from seeing a new component reference on +// every parent render, which would unmount/remount the inputs and lose focus. +function SharedFields({ + form, + igOptions, +}: { + form: ReturnType>; + igOptions: { value: string; label: string }[]; +}) { + return ( + <> + ( + + About You + +