Skip to content
Open
38 changes: 38 additions & 0 deletions src/api/client.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
13 changes: 11 additions & 2 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ interface RequestOptions<T> {
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;
}
Expand All @@ -75,6 +77,7 @@ type ClientOptions = {
responseType?: "json" | "blob";
isFormData?: boolean;
skipAuthRedirectOn403?: boolean;
expectedErrorStatuses?: readonly number[];
strictSchema?: boolean;
};

Expand Down Expand Up @@ -208,7 +211,10 @@ async function request<T>(
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,
Expand All @@ -222,7 +228,10 @@ async function request<T>(
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,
Expand Down
4 changes: 4 additions & 0 deletions src/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
},

// ============================================
Expand Down
53 changes: 40 additions & 13 deletions src/components/ui/multi-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -17,6 +18,8 @@ interface MultiSelectProps {
placeholder?: string;
disabled?: boolean;
className?: string;
maxSelections?: number;
minSelections?: number;
}

export function MultiSelect({
Expand All @@ -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("");
Expand All @@ -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]);
}
};
Expand Down Expand Up @@ -91,19 +104,26 @@ export function MultiSelect({
{selectedLabels.length === 0 ? (
<span className="text-muted-foreground flex-1">{placeholder}</span>
) : (
selectedLabels.map((o) => (
<Badge key={o.value} variant="secondary" className="gap-1 pr-1">
{o.label}
<button
type="button"
aria-label={`Remove ${o.label}`}
onClick={(e) => remove(o.value, e)}
className="rounded-sm opacity-60 hover:opacity-100 cursor-pointer"
>
<X className="h-3 w-3" />
</button>
</Badge>
))
<>
{selectedLabels.map((o) => (
<Badge key={o.value} variant="secondary" className="gap-1 pr-1">
{o.label}
<button
type="button"
aria-label={`Remove ${o.label}`}
onClick={(e) => remove(o.value, e)}
className="rounded-sm opacity-60 hover:opacity-100 cursor-pointer"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
{maxSelections !== undefined && (
<span className="ml-auto text-xs text-muted-foreground whitespace-nowrap">
{value.length}/{maxSelections}
</span>
)}
</>
)}
<ChevronDown
className={cn(
Expand Down Expand Up @@ -131,15 +151,22 @@ export function MultiSelect({
) : (
filtered.map((o) => {
const selected = value.includes(o.value);
const atMax =
maxSelections !== undefined &&
value.length >= maxSelections &&
!selected;
return (
<button
key={o.value}
type="button"
onClick={() => toggle(o.value)}
disabled={atMax}
className={cn(
"flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm outline-none transition-colors",
"hover:bg-accent hover:text-accent-foreground",
selected && "bg-accent/50",
atMax &&
"opacity-40 cursor-not-allowed hover:bg-transparent hover:text-current",
)}
>
<Check
Expand Down
62 changes: 32 additions & 30 deletions src/features/home/components/event-calendar-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,40 +286,42 @@ export function EventCalendarCard({
{format(selectedDate, "EEEE, MMM d")}
</p>
<div className="max-h-36 space-y-1.5 overflow-y-auto scrollbar-none">
{selectedDateEvents.map((event) => (
<div
key={event.id}
className="flex items-center gap-2.5 rounded-xl bg-card p-2.5 transition-all hover:-translate-y-0.5"
>
<div
{selectedDateEvents.map((event) => {
const Wrapper = event.link ? "a" : "div";
return (
<Wrapper
key={event.id}
href={event.link}
className={cn(
"size-2 shrink-0 rounded-full",
getEventBg(event.type ?? "other"),
"group flex items-center gap-2.5 rounded-xl bg-card p-2.5 transition-all hover:-translate-y-0.5",
event.link && "cursor-pointer hover:bg-accent/50",
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-foreground">
{event.title}
</p>
{event.location && (
<p className="flex items-center gap-1 text-[10px] text-muted-foreground">
<MapPin className="size-2.5" />
{event.location}
>
<div
className={cn(
"size-2 shrink-0 rounded-full",
getEventBg(event.type ?? "other"),
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-foreground group-hover:text-primary transition-colors">
{event.title}
</p>
{event.location && (
<p className="flex items-center gap-1 text-[10px] text-muted-foreground">
<MapPin className="size-2.5" />
{event.location}
</p>
)}
</div>
{event.link && (
<div className="shrink-0 text-primary transition-colors opacity-70 group-hover:opacity-100">
<ExternalLink className="size-3.5" />
</div>
)}
</div>
{event.link && (
<a
href={event.link}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-primary transition-colors hover:text-primary/80"
>
<ExternalLink className="size-3.5" />
</a>
)}
</div>
))}
</Wrapper>
);
})}
</div>
</div>
) : (
Expand Down
Loading
Loading