Skip to content

Commit 2a0f43e

Browse files
authored
[OA][Fullstack] - OA Websockets (#278)
* progress * modal + handling skeleton and stateful code editor * prettier * update tokenschema name
1 parent 55e00b3 commit 2a0f43e

16 files changed

Lines changed: 251 additions & 42 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"class-variance-authority": "^0.7.1",
6060
"clsx": "^2.1.1",
6161
"cmdk": "^1.1.1",
62+
"jose": "^6.2.2",
6263
"lucide-react": "^0.548.0",
6364
"next": "15.5.7",
6465
"prisma": "^6.15.0",
@@ -67,6 +68,7 @@
6768
"react-hook-form": "^7.65.0",
6869
"react-markdown": "^10.1.0",
6970
"react-resizable-panels": "^3.0.6",
71+
"react-use-websocket": "^4.13.0",
7072
"sonner": "^2.0.7",
7173
"tailwind-merge": "^3.3.1",
7274
"zod": "^4.1.9"

pnpm-lock.yaml

Lines changed: 21 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/(web)/(oa)/assessment/[assessmentId]/page.tsx

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ import AssessmentOutro from '@/lib/components/assessment-flow/AssessmentOutro';
77
import AssessmentSidebar from '@/lib/components/assessment-flow/AssessmentSidebar';
88
import AssessmentNavbar from '@/lib/components/assessment-flow/AssessmentNavbar';
99
import AssessmentContent from '@/lib/components/assessment-flow/AssessmentContent';
10+
import { useHeartbeat } from '@/lib/hooks/useHeartbeat';
11+
import { LostConnectionModal } from '@/lib/components/modal/LostConnectionModal';
12+
import AssessmentSkeleton from '@/lib/components/assessment-flow/AssessmentSkeleton';
1013

1114
export default function AssessmentPage({ params }: { params: Promise<{ assessmentId: string }> }) {
1215
const { assessmentId } = use(params);
1316
const assessment = useAssessment(assessmentId);
17+
const { isConnected } = useHeartbeat(assessment.token ?? null);
1418

1519
if (assessment.isLoading)
1620
return (
@@ -46,6 +50,8 @@ export default function AssessmentPage({ params }: { params: Promise<{ assessmen
4650

4751
return (
4852
<div className="flex h-screen w-full flex-col overflow-hidden">
53+
{/* onOpenChange is returning nothing as we don't have recovery implemented just yet */}
54+
<LostConnectionModal open={!isConnected} onOpenChange={() => {}} />
4955
<AssessmentNavbar candidateName={assessment.candidateName} />
5056
<div className="flex flex-1 overflow-hidden">
5157
<AssessmentSidebar
@@ -54,17 +60,21 @@ export default function AssessmentPage({ params }: { params: Promise<{ assessmen
5460
formattedTime={assessment.timer.formattedTime}
5561
/>
5662
<main className="flex-1 overflow-hidden">
57-
<AssessmentContent
58-
currentSection={assessment.currentSection}
59-
availableLanguages={assessment.availableLanguages}
60-
publicTestCases={assessment.publicTestCases}
61-
testCaseResults={assessment.testCaseResults}
62-
isTransitioning={assessment.isTransitioning}
63-
onLanguageChange={assessment.changeLanguage}
64-
onEditorMount={assessment.handleEditorMount}
65-
onRunTests={assessment.runTests}
66-
onSubmit={assessment.submitAndContinue}
67-
/>
63+
{!isConnected ? (
64+
<AssessmentSkeleton />
65+
) : (
66+
<AssessmentContent
67+
currentSection={assessment.currentSection}
68+
availableLanguages={assessment.availableLanguages}
69+
publicTestCases={assessment.publicTestCases}
70+
testCaseResults={assessment.testCaseResults}
71+
isTransitioning={assessment.isTransitioning}
72+
onLanguageChange={assessment.changeLanguage}
73+
onEditorMount={assessment.handleEditorMount}
74+
onRunTests={assessment.runTests}
75+
onSubmit={assessment.submitAndContinue}
76+
/>
77+
)}
6878
</main>
6979
</div>
7080
</div>

src/app/api/oa/[assessmentId]/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export async function GET(
77
{ params }: { params: Promise<{ assessmentId: string }> }
88
) {
99
try {
10+
// TODO: add route security
1011
const { assessmentId } = await params;
1112
const result = await AssessmentService.getAssessmentForCandidate(assessmentId);
1213
return Response.json({ data: result }, { status: 200 });

src/app/api/token/route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { GenerateOATokenSchema } from '@/lib/schemas/token.schema';
2+
import { handleError } from '@/lib/utils/errors.utils';
3+
import { generateToken } from '@/lib/utils/token.utils';
4+
import { type NextRequest } from 'next/server';
5+
6+
export async function POST(request: NextRequest) {
7+
try {
8+
// TODO: add route security
9+
const body = await request.json();
10+
const parsed = GenerateOATokenSchema.parse(body);
11+
const token = await generateToken(parsed.email);
12+
13+
return Response.json({ data: token }, { status: 201 });
14+
} catch (err) {
15+
return handleError(err);
16+
}
17+
}

src/lib/api/token.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* POST /api/token
3+
*/
4+
export async function createToken(email: string): Promise<string> {
5+
const res = await fetch(`/api/token`, {
6+
method: 'POST',
7+
headers: {
8+
'Content-Type': 'application/json',
9+
},
10+
body: JSON.stringify({ email }),
11+
});
12+
13+
const json = await res.json();
14+
15+
if (!res.ok) {
16+
throw new Error(json.message);
17+
}
18+
19+
return json.data;
20+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Skeleton } from '@/lib/components/ui/Skeleton';
2+
import {
3+
ResizablePanelGroup,
4+
ResizablePanel,
5+
ResizableHandle,
6+
} from '@/lib/components/ui/Resizable';
7+
import { useMemo } from 'react';
8+
9+
export default function AssessmentSkeleton() {
10+
// generating random line widths for the skelton code lines
11+
const skeletonCodeLineWidths = useMemo(
12+
() => Array.from({ length: 16 }, () => `${Math.random() * 40 + 30}%`),
13+
[]
14+
);
15+
16+
return (
17+
<ResizablePanelGroup direction="horizontal" className="h-full">
18+
<ResizablePanel defaultSize={35} minSize={20}>
19+
<div className="flex h-full flex-col">
20+
<div className="box-content shrink-0 px-5 pt-4 pb-2">
21+
<Skeleton className="h-9 w-3/4 rounded-md" />
22+
</div>
23+
<div className="flex-1 space-y-3 overflow-hidden px-5 pt-1 pb-4">
24+
<Skeleton className="h-4 w-full rounded" />
25+
<Skeleton className="h-4 w-5/6 rounded" />
26+
<Skeleton className="h-4 w-full rounded" />
27+
<Skeleton className="h-4 w-4/6 rounded" />
28+
<Skeleton className="h-4 w-full rounded" />
29+
<Skeleton className="h-4 w-3/4 rounded" />
30+
<div className="space-y-3 pt-2">
31+
<Skeleton className="h-4 w-full rounded" />
32+
<Skeleton className="h-4 w-5/6 rounded" />
33+
<Skeleton className="h-4 w-full rounded" />
34+
</div>
35+
</div>
36+
</div>
37+
</ResizablePanel>
38+
39+
<ResizableHandle className="bg-sarge-gray-200 w-px" />
40+
41+
<ResizablePanel defaultSize={65} minSize={40}>
42+
<div className="flex h-full flex-col overflow-hidden bg-[#3a414f]">
43+
<div className="min-h-0 flex-1 space-y-2 px-4 pt-4">
44+
{skeletonCodeLineWidths.map((w, i) => (
45+
<Skeleton key={i} className="h-4 rounded" style={{ width: w }} />
46+
))}
47+
</div>
48+
<div className="border-sarge-gray-200 flex items-center justify-between border-t bg-white px-4 py-3">
49+
<div className="flex gap-2">
50+
<Skeleton className="h-8 w-24 rounded-md" />
51+
</div>
52+
<div className="flex gap-2">
53+
<Skeleton className="h-8 w-24 rounded-md" />
54+
<Skeleton className="h-8 w-24 rounded-md" />
55+
</div>
56+
</div>
57+
</div>
58+
</ResizablePanel>
59+
</ResizablePanelGroup>
60+
);
61+
}

src/lib/components/assessment-flow/AssessmentTestCasesPanel.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ChevronDown, ChevronUp, CircleCheck, CircleX, Play, ChevronRight } from
55
import TestCaseCard from '@/lib/components/core/TestCaseCard';
66
import type { TestCaseDTO } from '@/lib/schemas/task-template.schema';
77
import type { TestCaseResult } from '@/lib/types/candidate-assessment.types';
8+
import { Button } from '@/lib/components/ui/Button';
89

910
type AssessmentTestCasesPanelProps = {
1011
testCases: TestCaseDTO[];
@@ -82,8 +83,8 @@ export default function AssessmentTestCasesPanel({
8283
<div className="flex h-15 flex-shrink-0 items-center justify-between px-3">
8384
<div className="flex items-center gap-4">
8485
<div className="flex items-center gap-1">
85-
<button
86-
type="button"
86+
<Button
87+
variant="icon"
8788
onClick={() => setIsOpen((v) => !v)}
8889
className="text-sarge-gray-700"
8990
>
@@ -92,7 +93,7 @@ export default function AssessmentTestCasesPanel({
9293
) : (
9394
<ChevronUp className="size-5" />
9495
)}
95-
</button>
96+
</Button>
9697
<span className="text-sarge-gray-700 text-sm font-medium">Test Cases</span>
9798
</div>
9899
{hasResults && (
@@ -113,22 +114,22 @@ export default function AssessmentTestCasesPanel({
113114
)}
114115
</div>
115116
<div className="flex items-center gap-2">
116-
<button
117-
type="button"
117+
<Button
118+
variant="secondary"
118119
onClick={onRunTests}
119-
className="bg-sarge-gray-50 border-sarge-primary-500 text-sarge-primary-500 flex h-9 items-center gap-2 rounded-lg border px-4 text-sm font-medium"
120+
className="flex h-9 items-center gap-2 rounded-lg border px-4 text-sm font-medium"
120121
>
121122
<Play className="size-4" />
122123
Run Tests
123-
</button>
124-
<button
125-
type="button"
124+
</Button>
125+
<Button
126+
variant="primary"
126127
onClick={onSubmit}
127-
className="bg-sarge-primary-500 text-primary-foreground flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium"
128+
className="text-primary-foreground flex h-9 items-center gap-2 rounded-lg px-4 text-sm font-medium"
128129
>
129130
Submit
130131
<ChevronRight className="size-4" />
131-
</button>
132+
</Button>
132133
</div>
133134
</div>
134135

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Dialog, DialogContent, DialogTitle } from '@/lib/components/ui/Modal';
2+
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
3+
import { Button } from '@/lib/components/ui/Button';
4+
5+
export type LostConnectionModalProps = {
6+
open: boolean;
7+
onOpenChange: (open: boolean) => void;
8+
};
9+
10+
export function LostConnectionModal({ open, onOpenChange }: LostConnectionModalProps) {
11+
return (
12+
<Dialog open={open} onOpenChange={onOpenChange}>
13+
<DialogContent className="h-[234px] w-[381px] gap-4" showCloseButton={true}>
14+
<VisuallyHidden>
15+
<DialogTitle>Network Disconnected</DialogTitle>
16+
</VisuallyHidden>
17+
<div className="flex flex-col items-center gap-6 text-center">
18+
<p className="text-sarge-gray-800 mt-2 text-base leading-tight font-medium tracking-wide">
19+
You seem to be having issues with your connection.
20+
</p>
21+
<p>
22+
If you disconnect again during this exam, your current progress will be
23+
auto-submitted.
24+
</p>
25+
<Button className="px-4 py-2">I understand</Button>
26+
</div>
27+
</DialogContent>
28+
</Dialog>
29+
);
30+
}

0 commit comments

Comments
 (0)