Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/actions/admin/update-course.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use server";

import { adminClient } from "@/lib/safe-action";
import { db } from "@/db";
import { courses, coursesTags, tags } from "@/db/schema";
import { eq, inArray } from "drizzle-orm";
import { z } from "zod";

// validation schema for updating a course
const updateCourseSchema = z.object({
id: z.number(),
title: z.string().min(1),
description: z.string().optional(),
difficulty: z.enum(["beginner", "intermediate", "advanced"]),
tags: z.array(z.string()).optional(), // safe to ignore for now
});

export const updateCourseAction = adminClient
.inputSchema(updateCourseSchema)
.action(async ({ parsedInput }) => {
const { id, title, description, difficulty, tags: tagNames } = parsedInput;

// make sure the course exists
const existing = await db
.select()
.from(courses)
.where(eq(courses.id, id));

if (existing.length === 0) {
return { success: false, serverError: "Course not found." };
}

// update the course fields
await db
.update(courses)
.set({
title,
description,
difficulty,
updatedAt: new Date(),
})
.where(eq(courses.id, id));

// tag updating logic
if (tagNames && tagNames.length > 0) {
await db.delete(coursesTags).where(eq(coursesTags.courseId, id));

const dbTags = await db
.select()
.from(tags)
.where(inArray(tags.tagName, tagNames));

for (const tag of dbTags) {
await db.insert(coursesTags).values({
courseId: id,
tagId: tag.id,
});
}
}

return { success: true, message: "Course updated successfully." };
});
119 changes: 119 additions & 0 deletions src/app/admin/courses/[courseId]/edit/EditCourseForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"use client";

import { useState, useTransition } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

import { updateCourseAction } from "@/actions/admin/update-course";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useRouter } from "next/navigation";
import { useAction } from "next-safe-action/hooks";

// zod form validation schema
const formSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
difficulty: z.enum(["beginner", "intermediate", "advanced"]),
});

export default function EditCourseForm({ course }: { course: any }) {
const router = useRouter();

// `useTransition` lets us show a pending state during async work
const [isPending, startTransition] = useTransition();

// holds any server side validation errors from the action
const [serverError, setServerError] = useState("");

const {executeAsync: updateCourse} = useAction(updateCourseAction, {
onSuccess: () => {
router.push("/admin/courses");
},
onError: ({error}) => {
console.log(error);
setServerError(error.serverError ?? "Something went wrong");
},
});

// react-hook-form setup with Zod resolver and default values
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: course.title,
description: course.description ?? "",
difficulty: course.difficulty ?? "beginner",
},
});

// handle form submission
const onSubmit = (values: z.infer<typeof formSchema>) => {
setServerError("");

// run async update inside startTransition for smoother UX
startTransition(async () => {
await updateCourse({
id: course.id, // pass the course id
...values, // pass updated form values
});
});
};

return (
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6 max-w-xl"
>
{/* Server-side error display */}
{serverError && (
<p className="text-red-500 text-sm">{serverError}</p>
)}

{/* course title Input*/}
<div>
<label className="block font-medium mb-1">Title</label>
<Input
placeholder="Course title"
disabled={isPending}
{...form.register("title")}
/>
{form.formState.errors.title && (
<p className="text-red-500 text-sm mt-1">
{form.formState.errors.title.message}
</p>
)}
</div>

{/* course description input */}
<div>
<label className="block font-medium mb-1">Description</label>
<Textarea
placeholder="Course description"
disabled={isPending}
{...form.register("description")}
/>
</div>

{/* difficulty dropdown menu */}
<div>
<label className="block font-medium mb-1">Difficulty</label>
<select
className="border rounded p-2 w-full"
disabled={isPending}
{...form.register("difficulty")}
>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
</select>
</div>

{/* submit button */}
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save Changes"}
</Button>
</form>
);
}
44 changes: 39 additions & 5 deletions src/app/admin/courses/[courseId]/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,40 @@
import type { CourseIdPageProps } from "@/lib/types";
import { db } from "@/db";
import { courses } from "@/db/schema";
import { eq } from "drizzle-orm";
import EditCourseForm from "./EditCourseForm";

export default async function Page({ params }: CourseIdPageProps) {
const { courseId } = await params;
return <div>Edit page for courseID: {courseId}</div>;
}
export default async function EditCoursePage({
params,
}: {
params: Promise<{ courseId: string }>;
}) {
// Next.js route params must be awaited
const { courseId } = await params;

// convert URL param from string to number
const id = Number(courseId);

// guard against invalid IDs
if (isNaN(id)) {
return <div>Invalid course id.</div>;
}

// fetch course row by ID using Drizzle ORM (returns array)
const [course] = await db
.select()
.from(courses)
.where(eq(courses.id, id));

// course not found error
if (!course) {
return <div>Course not found.</div>;
}

// if the course exists, render the edit page and pass the course record into the EditCourseForm component
return (
<div className="container mx-auto py-10">
<h1 className="text-3xl font-bold mb-6">Edit Course</h1>
<EditCourseForm course={course} />
</div>
);
}
11 changes: 6 additions & 5 deletions src/components/admin/CourseTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ const columns: ColumnDef<CourseWithData>[] = [
id: "actions",
enableHiding: false,
cell: ({ row }) => {
const course = row.original;

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
Expand All @@ -151,11 +153,10 @@ const columns: ColumnDef<CourseWithData>[] = [
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => console.log("edit")}
>
Edit
<DropdownMenuItem asChild>
<Link href={`/admin/courses/${course.id}/edit`} className="cursor-pointer">
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
Expand Down