Skip to content

Commit 62e77a5

Browse files
committed
Feature Create Lesson Page: implement Create Lesson page with unit dialog, form validation, and backend actions
1 parent 3ff5e27 commit 62e77a5

File tree

5 files changed

+419
-77
lines changed

5 files changed

+419
-77
lines changed
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import { useRouter } from "next/navigation";
5+
import { useForm } from "react-hook-form";
6+
import { zodResolver } from "@hookform/resolvers/zod";
7+
import { useAction } from "next-safe-action/hooks";
8+
9+
import {
10+
lessonFormSchema,
11+
type LessonFormSchema,
12+
} from "@/lib/lesson";
13+
import { createLessonAction } from "@/lib/lessonActions";
14+
15+
import {
16+
Form,
17+
FormField,
18+
FormItem,
19+
FormLabel,
20+
FormControl,
21+
FormMessage,
22+
} from "@/components/ui/form";
23+
import { Input } from "@/components/ui/input";
24+
import { Textarea } from "@/components/ui/textarea";
25+
import { Button } from "@/components/ui/button";
26+
import {
27+
Select,
28+
SelectTrigger,
29+
SelectContent,
30+
SelectItem,
31+
SelectValue,
32+
} from "@/components/ui/select";
33+
34+
import { CreateUnitDialog } from "@/components/CreateUnitDialog";
35+
36+
type UnitOption = {
37+
id: number;
38+
title: string | null;
39+
};
40+
41+
type CreateLessonFormProps = {
42+
courseId: string;
43+
initialUnits: UnitOption[];
44+
};
45+
46+
const NEW_UNIT_VALUE = "__new_unit__";
47+
48+
export function CreateLessonForm({
49+
courseId,
50+
initialUnits,
51+
}: CreateLessonFormProps) {
52+
const router = useRouter();
53+
54+
const [units, setUnits] = React.useState<UnitOption[]>(initialUnits);
55+
const [openUnitDialog, setOpenUnitDialog] = React.useState(false);
56+
57+
// Relax types around useForm to avoid resolver/control generic issues
58+
const form = useForm<LessonFormSchema>({
59+
resolver: zodResolver(lessonFormSchema) as any,
60+
defaultValues: {
61+
title: "",
62+
description: "",
63+
unitId: 0,
64+
courseId,
65+
},
66+
}) as any;
67+
68+
const { execute, status, result } = useAction(createLessonAction, {
69+
onSuccess: ({ data }) => {
70+
if (data?.success) {
71+
router.push(`/admin/courses/${courseId}`);
72+
}
73+
},
74+
});
75+
76+
const isSubmitting = status === "executing";
77+
78+
const onSubmit = (values: LessonFormSchema) => {
79+
execute(values);
80+
};
81+
82+
const handleUnitChange = (
83+
value: string,
84+
onChange: (val: number) => void
85+
) => {
86+
if (value === NEW_UNIT_VALUE) {
87+
setOpenUnitDialog(true);
88+
return;
89+
}
90+
onChange(Number(value));
91+
};
92+
93+
const handleUnitCreated = (unit: { id: number; title: string | null }) => {
94+
setUnits((prev) => [...prev, unit]);
95+
form.setValue("unitId", unit.id, { shouldValidate: true });
96+
};
97+
98+
return (
99+
<>
100+
<CreateUnitDialog
101+
open={openUnitDialog}
102+
onOpenChange={setOpenUnitDialog}
103+
courseId={courseId}
104+
onCreated={handleUnitCreated}
105+
/>
106+
107+
<Form {...form}>
108+
<form
109+
onSubmit={form.handleSubmit(onSubmit)}
110+
className="space-y-6 max-w-xl"
111+
>
112+
<FormField
113+
control={form.control}
114+
name="title"
115+
render={({ field }) => (
116+
<FormItem>
117+
<FormLabel>Lesson title</FormLabel>
118+
<FormControl>
119+
<Input placeholder="e.g. What is a variable?" {...field} />
120+
</FormControl>
121+
<FormMessage />
122+
</FormItem>
123+
)}
124+
/>
125+
126+
<FormField
127+
control={form.control}
128+
name="description"
129+
render={({ field }) => (
130+
<FormItem>
131+
<FormLabel>Description</FormLabel>
132+
<FormControl>
133+
<Textarea
134+
placeholder="Short summary of this lesson..."
135+
{...field}
136+
/>
137+
</FormControl>
138+
<FormMessage />
139+
</FormItem>
140+
)}
141+
/>
142+
143+
{/* Hidden courseId so it gets sent along with the form */}
144+
<input type="hidden" {...form.register("courseId")} value={courseId} />
145+
146+
<FormField
147+
control={form.control}
148+
name="unitId"
149+
render={({ field }) => (
150+
<FormItem>
151+
<FormLabel>Unit</FormLabel>
152+
<Select
153+
value={field.value ? String(field.value) : ""}
154+
onValueChange={(val) => handleUnitChange(val, field.onChange)}
155+
>
156+
<FormControl>
157+
<SelectTrigger>
158+
<SelectValue placeholder="Select a unit" />
159+
</SelectTrigger>
160+
</FormControl>
161+
<SelectContent>
162+
{units.map((unit) => (
163+
<SelectItem key={unit.id} value={String(unit.id)}>
164+
{unit.title ?? `Unit ${unit.id}`}
165+
</SelectItem>
166+
))}
167+
<SelectItem value={NEW_UNIT_VALUE}>
168+
+ Create new unit
169+
</SelectItem>
170+
</SelectContent>
171+
</Select>
172+
<FormMessage />
173+
</FormItem>
174+
)}
175+
/>
176+
177+
{result.serverError && (
178+
<p className="text-sm text-destructive">
179+
{String(result.serverError)}
180+
</p>
181+
)}
182+
183+
<div className="flex gap-2">
184+
<Button
185+
type="button"
186+
variant="outline"
187+
onClick={() => router.back()}
188+
>
189+
Cancel
190+
</Button>
191+
<Button type="submit" disabled={isSubmitting}>
192+
{isSubmitting ? "Creating..." : "Create lesson"}
193+
</Button>
194+
</div>
195+
</form>
196+
</Form>
197+
</>
198+
);
199+
}
Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,41 @@
11
// src/app/admin/courses/[courseId]/lesson/create/page.tsx
2-
import React from "react";
32

4-
export default async function CreateLessonPage({
5-
params,
6-
}: {
3+
import { db } from "@/db/index";
4+
import { units } from "@/db/schema";
5+
import { eq } from "drizzle-orm";
6+
import { CreateLessonForm } from "./CreateLessonForm";
7+
8+
type PageProps = {
79
params: Promise<{ courseId: string }>;
8-
}) {
10+
};
11+
12+
export default async function CreateLessonPage({ params }: PageProps) {
913
const { courseId } = await params;
1014

11-
return (
12-
<div className="container mx-auto max-w-5xl space-y-6 p-6">
13-
<h1 className="text-2xl font-semibold tracking-tight">New Lesson</h1>
14-
<p className="text-sm text-muted-foreground">
15-
Create a lesson for course <span className="font-mono">{courseId}</span>.
16-
</p>
15+
// courseId in the URL will be something like "1"
16+
const courseUnits = await db
17+
.select()
18+
.from(units)
19+
.where(eq(units.courseId, courseId));
1720

18-
{/* Placeholder until LessonForm is added */}
19-
<div className="rounded-lg border bg-card p-6 text-sm text-foreground/80 dark:border-neutral-800">
20-
Form coming next: unit dropdown, media type, content URL/blob, create-unit modal.
21+
return (
22+
<div className="space-y-6">
23+
<div>
24+
<h1 className="text-2xl font-semibold tracking-tight">
25+
Create lesson
26+
</h1>
27+
<p className="text-sm text-muted-foreground">
28+
Add a new lesson and assign it to a unit in this course.
29+
</p>
2130
</div>
31+
32+
<CreateLessonForm
33+
courseId={courseId}
34+
initialUnits={courseUnits.map((u) => ({
35+
id: u.id,
36+
title: u.title ?? `Unit ${u.id}`,
37+
}))}
38+
/>
2239
</div>
2340
);
2441
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import { useForm } from "react-hook-form";
5+
import { zodResolver } from "@hookform/resolvers/zod";
6+
import { useAction } from "next-safe-action/hooks";
7+
8+
import {
9+
createUnitAction,
10+
} from "@/lib/lessonActions";
11+
import {
12+
createUnitSchema,
13+
type CreateUnitSchema,
14+
} from "@/lib/lesson";
15+
16+
import {
17+
Dialog,
18+
DialogContent,
19+
DialogHeader,
20+
DialogTitle,
21+
DialogFooter,
22+
} from "@/components/ui/dialog";
23+
import { Button } from "@/components/ui/button";
24+
import {
25+
Form,
26+
FormField,
27+
FormItem,
28+
FormLabel,
29+
FormControl,
30+
FormMessage,
31+
} from "@/components/ui/form";
32+
import { Input } from "@/components/ui/input";
33+
34+
type CreateUnitDialogProps = {
35+
open: boolean;
36+
onOpenChange: (open: boolean) => void;
37+
courseId: string;
38+
onCreated?: (unit: { id: number; title: string | null }) => void;
39+
};
40+
41+
export function CreateUnitDialog({
42+
open,
43+
onOpenChange,
44+
courseId,
45+
onCreated,
46+
}: CreateUnitDialogProps) {
47+
const form = useForm<CreateUnitSchema>({
48+
resolver: zodResolver(createUnitSchema),
49+
defaultValues: {
50+
title: "",
51+
courseId,
52+
},
53+
});
54+
55+
const { execute, status, result } = useAction(createUnitAction, {
56+
onSuccess: ({ data }) => {
57+
if (data?.success) {
58+
onCreated?.({
59+
id: data.unitId,
60+
title: data.unitTitle,
61+
});
62+
form.reset({ title: "", courseId });
63+
onOpenChange(false);
64+
}
65+
},
66+
});
67+
68+
const isSubmitting = status === "executing";
69+
70+
const onSubmit = (values: CreateUnitSchema) => {
71+
execute(values);
72+
};
73+
74+
return (
75+
<Dialog open={open} onOpenChange={onOpenChange}>
76+
<DialogContent>
77+
<DialogHeader>
78+
<DialogTitle>Create new unit</DialogTitle>
79+
</DialogHeader>
80+
81+
<Form {...form}>
82+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
83+
<FormField
84+
control={form.control}
85+
name="title"
86+
render={({ field }) => (
87+
<FormItem>
88+
<FormLabel>Unit title</FormLabel>
89+
<FormControl>
90+
<Input placeholder="e.g. Introduction" {...field} />
91+
</FormControl>
92+
<FormMessage />
93+
</FormItem>
94+
)}
95+
/>
96+
97+
{result.serverError && (
98+
<p className="text-sm text-destructive">
99+
{String(result.serverError)}
100+
</p>
101+
)}
102+
103+
<DialogFooter>
104+
<Button
105+
type="button"
106+
variant="outline"
107+
onClick={() => onOpenChange(false)}
108+
>
109+
Cancel
110+
</Button>
111+
<Button type="submit" disabled={isSubmitting}>
112+
{isSubmitting ? "Creating..." : "Create unit"}
113+
</Button>
114+
</DialogFooter>
115+
</form>
116+
</Form>
117+
</DialogContent>
118+
</Dialog>
119+
);
120+
}
121+

src/lib/lesson.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// src/lib/validations/lesson.ts
2+
import { z } from "zod";
3+
4+
export const lessonFormSchema = z.object({
5+
title: z.string().min(1, "Title is required"),
6+
description: z.string().optional(),
7+
unitId: z.coerce.number().int().positive("Unit is required"),
8+
courseId: z.string().min(1),
9+
});
10+
11+
export type LessonFormSchema = z.infer<typeof lessonFormSchema>;
12+
13+
// For the “create unit” modal
14+
export const createUnitSchema = z.object({
15+
title: z.string().min(1, "Unit title is required"),
16+
courseId: z.string().min(1),
17+
});
18+
19+
export type CreateUnitSchema = z.infer<typeof createUnitSchema>;

0 commit comments

Comments
 (0)