Skip to content

Commit 0c03d71

Browse files
Made admin edit course feature (#66)
* Made admin edit course feature * added simple next-safe-action to return proper page errors --------- Co-authored-by: joshuasilva414 <joshuasilva414@gmail.com>
1 parent 5c740da commit 0c03d71

File tree

4 files changed

+226
-10
lines changed

4 files changed

+226
-10
lines changed

src/actions/admin/update-course.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"use server";
2+
3+
import { adminClient } from "@/lib/safe-action";
4+
import { db } from "@/db";
5+
import { courses, coursesTags, tags } from "@/db/schema";
6+
import { eq, inArray } from "drizzle-orm";
7+
import { z } from "zod";
8+
9+
// validation schema for updating a course
10+
const updateCourseSchema = z.object({
11+
id: z.number(),
12+
title: z.string().min(1),
13+
description: z.string().optional(),
14+
difficulty: z.enum(["beginner", "intermediate", "advanced"]),
15+
tags: z.array(z.string()).optional(), // safe to ignore for now
16+
});
17+
18+
export const updateCourseAction = adminClient
19+
.inputSchema(updateCourseSchema)
20+
.action(async ({ parsedInput }) => {
21+
const { id, title, description, difficulty, tags: tagNames } = parsedInput;
22+
23+
// make sure the course exists
24+
const existing = await db
25+
.select()
26+
.from(courses)
27+
.where(eq(courses.id, id));
28+
29+
if (existing.length === 0) {
30+
return { success: false, serverError: "Course not found." };
31+
}
32+
33+
// update the course fields
34+
await db
35+
.update(courses)
36+
.set({
37+
title,
38+
description,
39+
difficulty,
40+
updatedAt: new Date(),
41+
})
42+
.where(eq(courses.id, id));
43+
44+
// tag updating logic
45+
if (tagNames && tagNames.length > 0) {
46+
await db.delete(coursesTags).where(eq(coursesTags.courseId, id));
47+
48+
const dbTags = await db
49+
.select()
50+
.from(tags)
51+
.where(inArray(tags.tagName, tagNames));
52+
53+
for (const tag of dbTags) {
54+
await db.insert(coursesTags).values({
55+
courseId: id,
56+
tagId: tag.id,
57+
});
58+
}
59+
}
60+
61+
return { success: true, message: "Course updated successfully." };
62+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"use client";
2+
3+
import { useState, useTransition } from "react";
4+
import { useForm } from "react-hook-form";
5+
import { z } from "zod";
6+
import { zodResolver } from "@hookform/resolvers/zod";
7+
8+
import { updateCourseAction } from "@/actions/admin/update-course";
9+
import { Button } from "@/components/ui/button";
10+
import { Input } from "@/components/ui/input";
11+
import { Textarea } from "@/components/ui/textarea";
12+
import { useRouter } from "next/navigation";
13+
import { useAction } from "next-safe-action/hooks";
14+
15+
// zod form validation schema
16+
const formSchema = z.object({
17+
title: z.string().min(1, "Title is required"),
18+
description: z.string().optional(),
19+
difficulty: z.enum(["beginner", "intermediate", "advanced"]),
20+
});
21+
22+
export default function EditCourseForm({ course }: { course: any }) {
23+
const router = useRouter();
24+
25+
// `useTransition` lets us show a pending state during async work
26+
const [isPending, startTransition] = useTransition();
27+
28+
// holds any server side validation errors from the action
29+
const [serverError, setServerError] = useState("");
30+
31+
const {executeAsync: updateCourse} = useAction(updateCourseAction, {
32+
onSuccess: () => {
33+
router.push("/admin/courses");
34+
},
35+
onError: ({error}) => {
36+
console.log(error);
37+
setServerError(error.serverError ?? "Something went wrong");
38+
},
39+
});
40+
41+
// react-hook-form setup with Zod resolver and default values
42+
const form = useForm<z.infer<typeof formSchema>>({
43+
resolver: zodResolver(formSchema),
44+
defaultValues: {
45+
title: course.title,
46+
description: course.description ?? "",
47+
difficulty: course.difficulty ?? "beginner",
48+
},
49+
});
50+
51+
// handle form submission
52+
const onSubmit = (values: z.infer<typeof formSchema>) => {
53+
setServerError("");
54+
55+
// run async update inside startTransition for smoother UX
56+
startTransition(async () => {
57+
await updateCourse({
58+
id: course.id, // pass the course id
59+
...values, // pass updated form values
60+
});
61+
});
62+
};
63+
64+
return (
65+
<form
66+
onSubmit={form.handleSubmit(onSubmit)}
67+
className="space-y-6 max-w-xl"
68+
>
69+
{/* Server-side error display */}
70+
{serverError && (
71+
<p className="text-red-500 text-sm">{serverError}</p>
72+
)}
73+
74+
{/* course title Input*/}
75+
<div>
76+
<label className="block font-medium mb-1">Title</label>
77+
<Input
78+
placeholder="Course title"
79+
disabled={isPending}
80+
{...form.register("title")}
81+
/>
82+
{form.formState.errors.title && (
83+
<p className="text-red-500 text-sm mt-1">
84+
{form.formState.errors.title.message}
85+
</p>
86+
)}
87+
</div>
88+
89+
{/* course description input */}
90+
<div>
91+
<label className="block font-medium mb-1">Description</label>
92+
<Textarea
93+
placeholder="Course description"
94+
disabled={isPending}
95+
{...form.register("description")}
96+
/>
97+
</div>
98+
99+
{/* difficulty dropdown menu */}
100+
<div>
101+
<label className="block font-medium mb-1">Difficulty</label>
102+
<select
103+
className="border rounded p-2 w-full"
104+
disabled={isPending}
105+
{...form.register("difficulty")}
106+
>
107+
<option value="beginner">Beginner</option>
108+
<option value="intermediate">Intermediate</option>
109+
<option value="advanced">Advanced</option>
110+
</select>
111+
</div>
112+
113+
{/* submit button */}
114+
<Button type="submit" disabled={isPending}>
115+
{isPending ? "Saving..." : "Save Changes"}
116+
</Button>
117+
</form>
118+
);
119+
}
Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,40 @@
1-
import type { CourseIdPageProps } from "@/lib/types";
1+
import { db } from "@/db";
2+
import { courses } from "@/db/schema";
3+
import { eq } from "drizzle-orm";
4+
import EditCourseForm from "./EditCourseForm";
25

3-
export default async function Page({ params }: CourseIdPageProps) {
4-
const { courseId } = await params;
5-
return <div>Edit page for courseID: {courseId}</div>;
6-
}
6+
export default async function EditCoursePage({
7+
params,
8+
}: {
9+
params: Promise<{ courseId: string }>;
10+
}) {
11+
// Next.js route params must be awaited
12+
const { courseId } = await params;
13+
14+
// convert URL param from string to number
15+
const id = Number(courseId);
16+
17+
// guard against invalid IDs
18+
if (isNaN(id)) {
19+
return <div>Invalid course id.</div>;
20+
}
21+
22+
// fetch course row by ID using Drizzle ORM (returns array)
23+
const [course] = await db
24+
.select()
25+
.from(courses)
26+
.where(eq(courses.id, id));
27+
28+
// course not found error
29+
if (!course) {
30+
return <div>Course not found.</div>;
31+
}
32+
33+
// if the course exists, render the edit page and pass the course record into the EditCourseForm component
34+
return (
35+
<div className="container mx-auto py-10">
36+
<h1 className="text-3xl font-bold mb-6">Edit Course</h1>
37+
<EditCourseForm course={course} />
38+
</div>
39+
);
40+
}

src/components/admin/CourseTable.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ const columns: ColumnDef<CourseWithData>[] = [
141141
id: "actions",
142142
enableHiding: false,
143143
cell: ({ row }) => {
144+
const course = row.original;
145+
144146
return (
145147
<DropdownMenu>
146148
<DropdownMenuTrigger asChild>
@@ -151,11 +153,10 @@ const columns: ColumnDef<CourseWithData>[] = [
151153
</DropdownMenuTrigger>
152154
<DropdownMenuContent align="end">
153155
<DropdownMenuLabel>Actions</DropdownMenuLabel>
154-
<DropdownMenuItem
155-
className="cursor-pointer"
156-
onClick={() => console.log("edit")}
157-
>
158-
Edit
156+
<DropdownMenuItem asChild>
157+
<Link href={`/admin/courses/${course.id}/edit`} className="cursor-pointer">
158+
Edit
159+
</Link>
159160
</DropdownMenuItem>
160161
<DropdownMenuItem
161162
className="cursor-pointer"

0 commit comments

Comments
 (0)