From 141471c174132784e3829ae8024c44578c738750 Mon Sep 17 00:00:00 2001 From: Rajat Date: Tue, 24 Mar 2026 00:58:32 +0530 Subject: [PATCH 1/6] Re-arrange sections --- .../course-old/[slug]/[id]/helpers.ts | 6 +- .../[slug]/[id]/__tests__/helpers.test.ts | 59 +++ .../course/[slug]/[id]/helpers.ts | 6 +- .../(sidebar)/product/[id]/content/page.tsx | 489 ++++++++++-------- .../__tests__/drag-and-drop.test.tsx | 44 ++ .../courses/__tests__/reorder-groups.test.ts | 439 ++++++++++++++++ apps/web/graphql/courses/logic.ts | 59 +++ apps/web/graphql/courses/mutation.ts | 16 + .../components-library/src/drag-and-drop.tsx | 21 +- 9 files changed, 913 insertions(+), 226 deletions(-) create mode 100644 apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts create mode 100644 apps/web/components/__tests__/drag-and-drop.test.tsx create mode 100644 apps/web/graphql/courses/__tests__/reorder-groups.test.ts diff --git a/apps/web/app/(with-contexts)/course-old/[slug]/[id]/helpers.ts b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/helpers.ts index ea139709d..a8c8c127f 100644 --- a/apps/web/app/(with-contexts)/course-old/[slug]/[id]/helpers.ts +++ b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/helpers.ts @@ -92,7 +92,9 @@ export const getProduct = async ( export function formatCourse( post: Course & { lessons: Lesson[]; firstLesson: string; groups: Group[] }, ): CourseFrontend { - for (const group of sortCourseGroups(post as Course)) { + const sortedGroups = sortCourseGroups(post as Course); + + for (const group of sortedGroups) { (group as GroupWithLessons).lessons = post.lessons .filter((lesson: Lesson) => lesson.groupId === group.id) .sort( @@ -111,7 +113,7 @@ export function formatCourse( slug: post.slug, cost: post.cost, courseId: post.courseId, - groups: post.groups as GroupWithLessons[], + groups: sortedGroups as GroupWithLessons[], tags: post.tags, firstLesson: post.firstLesson, paymentPlans: post.paymentPlans, diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts new file mode 100644 index 000000000..6e543755f --- /dev/null +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts @@ -0,0 +1,59 @@ +import { formatCourse } from "../helpers"; + +describe("course helpers formatCourse", () => { + it("returns groups sorted by rank and lessons sorted by lessonsOrder", () => { + const formatted = formatCourse({ + title: "Course", + description: "{}", + featuredImage: undefined, + updatedAt: new Date().toISOString(), + creatorId: "creator", + slug: "course", + cost: 0, + courseId: "course-1", + tags: [], + paymentPlans: [], + defaultPaymentPlan: "", + firstLesson: "lesson-1", + groups: [ + { + id: "group-2", + name: "Group 2", + rank: 2000, + lessonsOrder: ["lesson-3", "lesson-2"], + }, + { + id: "group-1", + name: "Group 1", + rank: 1000, + lessonsOrder: ["lesson-1"], + }, + ], + lessons: [ + { + lessonId: "lesson-2", + title: "Lesson 2", + groupId: "group-2", + }, + { + lessonId: "lesson-1", + title: "Lesson 1", + groupId: "group-1", + }, + { + lessonId: "lesson-3", + title: "Lesson 3", + groupId: "group-2", + }, + ], + } as any); + + expect(formatted.groups.map((group) => group.id)).toEqual([ + "group-1", + "group-2", + ]); + expect( + formatted.groups[1].lessons.map((lesson) => lesson.lessonId), + ).toEqual(["lesson-3", "lesson-2"]); + }); +}); diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts b/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts index ea139709d..a8c8c127f 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts @@ -92,7 +92,9 @@ export const getProduct = async ( export function formatCourse( post: Course & { lessons: Lesson[]; firstLesson: string; groups: Group[] }, ): CourseFrontend { - for (const group of sortCourseGroups(post as Course)) { + const sortedGroups = sortCourseGroups(post as Course); + + for (const group of sortedGroups) { (group as GroupWithLessons).lessons = post.lessons .filter((lesson: Lesson) => lesson.groupId === group.id) .sort( @@ -111,7 +113,7 @@ export function formatCourse( slug: post.slug, cost: post.cost, courseId: post.courseId, - groups: post.groups as GroupWithLessons[], + groups: sortedGroups as GroupWithLessons[], tags: post.tags, firstLesson: post.firstLesson, paymentPlans: post.paymentPlans, diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx index e92c3f531..c6d93cf15 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useContext, useState } from "react"; +import { ComponentType, useContext, useEffect, useMemo, useState } from "react"; import { useRouter, useParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -45,7 +45,7 @@ import DashboardContent from "@components/admin/dashboard-content"; import { AddressContext } from "@components/contexts"; import useProduct from "@/hooks/use-product"; import { truncate } from "@ui-lib/utils"; -import { Constants, UIConstants } from "@courselit/common-models"; +import { Constants, Group, UIConstants } from "@courselit/common-models"; import { DragAndDrop, useToast } from "@courselit/components-library"; import { FetchBuilder } from "@courselit/utils"; import { @@ -56,6 +56,13 @@ import { } from "@/components/ui/tooltip"; const { permissions } = UIConstants; +const DragAndDropWithDisabled = DragAndDrop as ComponentType<{ + items: any; + onChange: any; + Renderer: any; + disabled?: boolean; +}>; + export default function ContentPage() { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [sectionMenuOpenId, setSectionMenuOpenId] = useState( @@ -66,14 +73,24 @@ export default function ContentPage() { string > | null>(null); const [collapsedSections, setCollapsedSections] = useState([]); - const [hoveredSectionIndex, setHoveredSectionIndex] = useState< - number | null - >(null); + const [orderedSections, setOrderedSections] = useState([]); + const [isReordering, setIsReordering] = useState(false); const router = useRouter(); const params = useParams(); const productId = params.id as string; const address = useContext(AddressContext); const { product } = useProduct(productId); + const sortedProductGroups = useMemo( + () => + [...(product?.groups ?? [])].sort( + (a, b) => (a.rank ?? 0) - (b.rank ?? 0), + ), + [product?.groups], + ); + + useEffect(() => { + setOrderedSections(sortedProductGroups); + }, [sortedProductGroups]); const breadcrumbs = [ { label: MANAGE_COURSES_PAGE_HEADING, href: "/dashboard/products" }, { @@ -174,6 +191,9 @@ export default function ContentPage() { try { const response = await fetch.exec(); if (response.removeGroup?.courseId) { + setOrderedSections((prev) => + prev.filter((section) => section.id !== groupId), + ); toast({ title: TOAST_TITLE_SUCCESS, description: LESSON_GROUP_DELETED, @@ -192,6 +212,58 @@ export default function ContentPage() { } }; + const reorderGroups = async ( + groupIds: string[], + fallbackSections: any[], + ) => { + if (!product?.courseId) { + return; + } + + const mutation = ` + mutation ReorderGroups($courseId: String!, $groupIds: [String!]!) { + course: reorderGroups(courseId: $courseId, groupIds: $groupIds) { + courseId + groups { + id + rank + } + } + } + `; + + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload({ + query: mutation, + variables: { + courseId: product.courseId, + groupIds, + }, + }) + .setIsGraphQLEndpoint(true) + .build(); + + setIsReordering(true); + try { + await fetch.exec(); + } catch (err: any) { + setOrderedSections(fallbackSections); + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setIsReordering(false); + } + }; + + const renderedSections = orderedSections.map((section) => ({ + id: section.id, + section, + })); + return ( - {product?.groups!.map((section, index) => ( -
setHoveredSectionIndex(index)} - onMouseLeave={() => setHoveredSectionIndex(null)} - > -
-
- -
-

- {section.name} -

- {section.drip && ( - - - - - - -

- This section has - scheduled release -

-
-
-
- )} -
-
- - setSectionMenuOpenId( - open ? section.id : null, - ) - } - > - + section.id) + .join(",")} + items={renderedSections} + disabled={isReordering} + onChange={(items: any[]) => { + if (isReordering) { + return; + } + + const fallbackSections = [...orderedSections]; + const nextSections = items.map((item) => item.section); + setOrderedSections(nextSections); + reorderGroups( + nextSections.map((section) => section.id), + fallbackSections, + ); + }} + Renderer={({ section }: { section: Group }) => ( +
+
+
- - - - router.push( - `/dashboard/product/${productId}/content/section/${section.id}`, - ) - } - > - {EDIT_SECTION_HEADER} - - {/* - router.push( - `/dashboard/product/${productId}/content/section/new?after=${section.id}`, - ) + toggleSectionCollapse(section.id) } + className="p-0 hover:bg-transparent" > - Add Section Below - */} - {!( - product?.type?.toLowerCase() === - Constants.CourseType.DOWNLOAD && - product?.groups?.length === 1 - ) && ( - <> - - { - setSectionMenuOpenId(null); - setItemToDelete({ - type: "section", - title: section.name, - id: section.id, - }); - setDeleteDialogOpen(true); - }} - className="text-red-600" - > - Delete Section - - - )} - - -
- {!collapsedSections.includes(section.id) && ( -
- {/* {section.lessons.map((lesson) => ( -
router.push(`/dashboard/product/${productId}/content/lesson?id=${lesson.id}`)} - > -
- - {lesson.title} -
- -
- ))} */} - - lesson.groupId === section.id, - ) - .sort( - (a: any, b: any) => - ( - section.lessonsOrder as any[] - )?.indexOf(a.lessonId) - - ( - section.lessonsOrder as any[] - )?.indexOf(b.lessonId), + {collapsedSections.includes( + section.id, + ) ? ( + + ) : ( + + )} + +
+

+ {section.name} +

+ {section.drip?.status && ( + + + + + + +

+ This section has + scheduled release +

+
+
+
+ )} +
+
+ + setSectionMenuOpenId( + open ? section.id : null, ) - .map((lesson) => ({ - id: lesson.lessonId, - courseId: product?.courseId, - groupId: lesson.groupId, - lesson, - }))} - Renderer={({ lesson }) => ( -
+ + + + + router.push( - `/dashboard/product/${productId}/content/section/${section.id}/lesson?id=${lesson.lessonId}`, + `/dashboard/product/${productId}/content/section/${section.id}`, ) } > -
- - - {lesson.title} - -
-
- {!lesson.published && ( - - Draft - - )} - -
-
- )} - key={JSON.stringify(product.lessons)} - onChange={(items: any) => { - const newLessonsOrder: any = items.map( - (item: { - lesson: { lessonId: any }; - }) => item.lesson.lessonId, - ); - updateGroup(section, newLessonsOrder); - }} - /> - + {EDIT_SECTION_HEADER} + + {!( + product?.type?.toLowerCase() === + Constants.CourseType.DOWNLOAD && + product?.groups?.length === 1 + ) && ( + <> + + { + setSectionMenuOpenId( + null, + ); + setItemToDelete({ + type: "section", + title: section.name, + id: section.id, + }); + setDeleteDialogOpen( + true, + ); + }} + className="text-red-600" + > + Delete Section + + + )} + +
- )} - {hoveredSectionIndex === index && ( -
- -
- )} -
- ))} + + + {product?.type?.toLowerCase() === + Constants.CourseType.DOWNLOAD + ? BUTTON_NEW_LESSON_TEXT_DOWNLOAD + : BUTTON_NEW_LESSON_TEXT} + + +
+ )} +
+ )} + /> {product?.type?.toLowerCase() !== Constants.CourseType.DOWNLOAD && (
diff --git a/apps/web/components/__tests__/drag-and-drop.test.tsx b/apps/web/components/__tests__/drag-and-drop.test.tsx new file mode 100644 index 000000000..d96a4f987 --- /dev/null +++ b/apps/web/components/__tests__/drag-and-drop.test.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import DragAndDrop from "../../../../packages/components-library/src/drag-and-drop"; + +describe("DragAndDrop", () => { + it("disables drag handles when disabled is true", () => { + render( +
{label}
} + />, + ); + + const handles = screen.getAllByTestId("drag-handle"); + expect(handles.length).toBeGreaterThan(0); + expect(handles.every((handle) => handle.hasAttribute("disabled"))).toBe( + true, + ); + }); + + it("keeps drag handles enabled by default", () => { + render( +
{label}
} + />, + ); + + const handles = screen.getAllByTestId("drag-handle"); + expect(handles.length).toBeGreaterThan(0); + expect( + handles.every((handle) => !handle.hasAttribute("disabled")), + ).toBe(true); + }); +}); diff --git a/apps/web/graphql/courses/__tests__/reorder-groups.test.ts b/apps/web/graphql/courses/__tests__/reorder-groups.test.ts new file mode 100644 index 000000000..20d71e766 --- /dev/null +++ b/apps/web/graphql/courses/__tests__/reorder-groups.test.ts @@ -0,0 +1,439 @@ +import DomainModel from "@models/Domain"; +import UserModel from "@models/User"; +import CourseModel from "@models/Course"; +import constants from "@/config/constants"; +import { responses } from "@/config/strings"; +import { reorderGroups } from "../logic"; + +const SUITE_PREFIX = `reorder-groups-${Date.now()}`; +const id = (suffix: string) => `${SUITE_PREFIX}-${suffix}`; +const email = (suffix: string) => `${suffix}-${SUITE_PREFIX}@example.com`; + +describe("reorderGroups", () => { + let testDomain: any; + let adminUser: any; + let ownerWithManageCourse: any; + let ownerWithoutManageCourse: any; + let otherUserWithManageCourse: any; + + beforeAll(async () => { + testDomain = await DomainModel.create({ + name: id("domain"), + email: email("domain"), + }); + + adminUser = await UserModel.create({ + domain: testDomain._id, + userId: id("admin-user"), + email: email("admin"), + name: "Admin User", + permissions: [constants.permissions.manageAnyCourse], + active: true, + unsubscribeToken: id("unsubscribe-admin"), + purchases: [], + }); + + ownerWithManageCourse = await UserModel.create({ + domain: testDomain._id, + userId: id("owner-manage-course"), + email: email("owner-manage-course"), + name: "Owner With ManageCourse", + permissions: [constants.permissions.manageCourse], + active: true, + unsubscribeToken: id("unsubscribe-owner-manage-course"), + purchases: [], + }); + + ownerWithoutManageCourse = await UserModel.create({ + domain: testDomain._id, + userId: id("owner-without-manage-course"), + email: email("owner-without-manage-course"), + name: "Owner Without ManageCourse", + permissions: [], + active: true, + unsubscribeToken: id("unsubscribe-owner-without-manage-course"), + purchases: [], + }); + + otherUserWithManageCourse = await UserModel.create({ + domain: testDomain._id, + userId: id("other-manage-course"), + email: email("other-manage-course"), + name: "Other User With ManageCourse", + permissions: [constants.permissions.manageCourse], + active: true, + unsubscribeToken: id("unsubscribe-other-manage-course"), + purchases: [], + }); + }); + + beforeEach(async () => { + await CourseModel.deleteMany({ domain: testDomain._id }); + }); + + afterAll(async () => { + await CourseModel.deleteMany({ domain: testDomain._id }); + await UserModel.deleteMany({ domain: testDomain._id }); + await DomainModel.deleteOne({ _id: testDomain._id }); + }); + + it("reorders groups atomically and rewrites sparse ranks", async () => { + const groupId1 = id("group-1"); + const groupId2 = id("group-2"); + const groupId3 = id("group-3"); + const course = await CourseModel.create({ + domain: testDomain._id, + courseId: id("course"), + title: id("course-title"), + creatorId: adminUser.userId, + groups: [ + { + _id: groupId1, + name: "Group 1", + rank: 1000, + collapsed: true, + lessonsOrder: [], + }, + { + _id: groupId2, + name: "Group 2", + rank: 2000, + collapsed: true, + lessonsOrder: [], + }, + { + _id: groupId3, + name: "Group 3", + rank: 3000, + collapsed: true, + lessonsOrder: [], + }, + ], + lessons: [], + type: "course", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: id("course-slug"), + }); + + await reorderGroups({ + courseId: course.courseId, + groupIds: [groupId3, groupId1, groupId2], + ctx: { + subdomain: testDomain, + user: adminUser, + address: "", + }, + }); + + const updatedCourse = await CourseModel.findOne({ + domain: testDomain._id, + courseId: course.courseId, + }).lean(); + + const rankById = new Map( + (updatedCourse?.groups ?? []).map((group: any) => [ + group._id.toString(), + group.rank, + ]), + ); + + expect(rankById.get(groupId3)).toBe(1000); + expect(rankById.get(groupId1)).toBe(2000); + expect(rankById.get(groupId2)).toBe(3000); + }); + + it("rejects duplicate group ids", async () => { + const groupId1 = id("group-dupe-1"); + const groupId2 = id("group-dupe-2"); + const groupId3 = id("group-dupe-3"); + const course = await CourseModel.create({ + domain: testDomain._id, + courseId: id("course-dupe"), + title: id("course-title-dupe"), + creatorId: adminUser.userId, + groups: [ + { + _id: groupId1, + name: "Group 1", + rank: 1000, + collapsed: true, + lessonsOrder: [], + }, + { + _id: groupId2, + name: "Group 2", + rank: 2000, + collapsed: true, + lessonsOrder: [], + }, + { + _id: groupId3, + name: "Group 3", + rank: 3000, + collapsed: true, + lessonsOrder: [], + }, + ], + lessons: [], + type: "course", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: id("course-slug-dupe"), + }); + + await expect( + reorderGroups({ + courseId: course.courseId, + groupIds: [groupId1, groupId1, groupId2], + ctx: { + subdomain: testDomain, + user: adminUser, + address: "", + }, + }), + ).rejects.toThrow(responses.invalid_input); + }); + + it("rejects permutations that do not include all groups", async () => { + const groupId1 = id("group-count-1"); + const groupId2 = id("group-count-2"); + const groupId3 = id("group-count-3"); + const course = await CourseModel.create({ + domain: testDomain._id, + courseId: id("course-count"), + title: id("course-title-count"), + creatorId: adminUser.userId, + groups: [ + { + _id: groupId1, + name: "Group 1", + rank: 1000, + collapsed: true, + lessonsOrder: [], + }, + { + _id: groupId2, + name: "Group 2", + rank: 2000, + collapsed: true, + lessonsOrder: [], + }, + { + _id: groupId3, + name: "Group 3", + rank: 3000, + collapsed: true, + lessonsOrder: [], + }, + ], + lessons: [], + type: "course", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: id("course-slug-count"), + }); + + await expect( + reorderGroups({ + courseId: course.courseId, + groupIds: [groupId1, groupId2], + ctx: { + subdomain: testDomain, + user: adminUser, + address: "", + }, + }), + ).rejects.toThrow(responses.invalid_input); + }); + + it("rejects unknown group ids", async () => { + const groupId1 = id("group-unknown-1"); + const groupId2 = id("group-unknown-2"); + const groupId3 = id("group-unknown-3"); + const unknownGroupId = id("group-unknown-missing"); + const course = await CourseModel.create({ + domain: testDomain._id, + courseId: id("course-unknown"), + title: id("course-title-unknown"), + creatorId: adminUser.userId, + groups: [ + { + _id: groupId1, + name: "Group 1", + rank: 1000, + collapsed: true, + lessonsOrder: [], + }, + { + _id: groupId2, + name: "Group 2", + rank: 2000, + collapsed: true, + lessonsOrder: [], + }, + { + _id: groupId3, + name: "Group 3", + rank: 3000, + collapsed: true, + lessonsOrder: [], + }, + ], + lessons: [], + type: "course", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: id("course-slug-unknown"), + }); + + await expect( + reorderGroups({ + courseId: course.courseId, + groupIds: [groupId1, unknownGroupId, groupId2], + ctx: { + subdomain: testDomain, + user: adminUser, + address: "", + }, + }), + ).rejects.toThrow(responses.invalid_input); + }); + + it("allows owners with manageCourse and rejects owners without permissions", async () => { + const groupId1 = id("group-owner-1"); + const groupId2 = id("group-owner-2"); + const ownerCourse = await CourseModel.create({ + domain: testDomain._id, + courseId: id("course-owner"), + title: id("course-title-owner"), + creatorId: ownerWithManageCourse.userId, + groups: [ + { + _id: groupId1, + name: "Group 1", + rank: 1000, + collapsed: true, + lessonsOrder: [], + }, + { + _id: groupId2, + name: "Group 2", + rank: 2000, + collapsed: true, + lessonsOrder: [], + }, + ], + lessons: [], + type: "course", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: id("course-slug-owner"), + }); + + await expect( + reorderGroups({ + courseId: ownerCourse.courseId, + groupIds: [groupId2, groupId1], + ctx: { + subdomain: testDomain, + user: ownerWithManageCourse, + address: "", + }, + }), + ).resolves.toBeTruthy(); + + const ownerNoPermissionCourse = await CourseModel.create({ + domain: testDomain._id, + courseId: id("course-owner-no-permission"), + title: id("course-title-owner-no-permission"), + creatorId: ownerWithoutManageCourse.userId, + groups: [ + { + _id: id("group-owner-no-permission-1"), + name: "Group 1", + rank: 1000, + collapsed: true, + lessonsOrder: [], + }, + { + _id: id("group-owner-no-permission-2"), + name: "Group 2", + rank: 2000, + collapsed: true, + lessonsOrder: [], + }, + ], + lessons: [], + type: "course", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: id("course-slug-owner-no-permission"), + }); + + await expect( + reorderGroups({ + courseId: ownerNoPermissionCourse.courseId, + groupIds: ownerNoPermissionCourse.groups.map((group: any) => + group.id.toString(), + ), + ctx: { + subdomain: testDomain, + user: ownerWithoutManageCourse, + address: "", + }, + }), + ).rejects.toThrow(responses.action_not_allowed); + }); + + it("rejects non-owner users with manageCourse", async () => { + const groupId1 = id("group-non-owner-1"); + const groupId2 = id("group-non-owner-2"); + const course = await CourseModel.create({ + domain: testDomain._id, + courseId: id("course-non-owner"), + title: id("course-title-non-owner"), + creatorId: adminUser.userId, + groups: [ + { + _id: groupId1, + name: "Group 1", + rank: 1000, + collapsed: true, + lessonsOrder: [], + }, + { + _id: groupId2, + name: "Group 2", + rank: 2000, + collapsed: true, + lessonsOrder: [], + }, + ], + lessons: [], + type: "course", + privacy: "unlisted", + costType: "free", + cost: 0, + slug: id("course-slug-non-owner"), + }); + + await expect( + reorderGroups({ + courseId: course.courseId, + groupIds: [groupId2, groupId1], + ctx: { + subdomain: testDomain, + user: otherUserWithManageCourse, + address: "", + }, + }), + ).rejects.toThrow(responses.item_not_found); + }); +}); diff --git a/apps/web/graphql/courses/logic.ts b/apps/web/graphql/courses/logic.ts index 0956112c2..c24bfebe6 100644 --- a/apps/web/graphql/courses/logic.ts +++ b/apps/web/graphql/courses/logic.ts @@ -906,6 +906,65 @@ export const updateGroup = async ({ ); }; +const GROUP_RANK_GAP = 1000; + +export const reorderGroups = async ({ + courseId, + groupIds, + ctx, +}: { + courseId: string; + groupIds: string[]; + ctx: GQLContext; +}) => { + const course = await getCourseOrThrow(undefined, ctx, courseId); + const existingGroupIds = (course.groups ?? []).map((group) => group.id); + + if (existingGroupIds.length !== groupIds.length) { + throw new Error(responses.invalid_input); + } + + if (new Set(groupIds).size !== groupIds.length) { + throw new Error(responses.invalid_input); + } + + const existingIdSet = new Set(existingGroupIds); + if (!groupIds.every((groupId) => existingIdSet.has(groupId))) { + throw new Error(responses.invalid_input); + } + + const rankByGroupId = new Map(); + groupIds.forEach((groupId, index) => { + rankByGroupId.set(groupId, (index + 1) * GROUP_RANK_GAP); + }); + + const updatedGroups = (course.groups ?? []).map((group) => { + const plainGroup = + typeof (group as any).toObject === "function" + ? (group as any).toObject() + : { ...group }; + + return { + ...plainGroup, + rank: rankByGroupId.get(group.id) ?? group.rank, + }; + }); + + await CourseModel.updateOne( + { + domain: ctx.subdomain._id, + courseId: course.courseId, + }, + { + $set: { + groups: updatedGroups, + }, + }, + ); + + return await formatCourse(course.courseId, ctx); +}; + export const getMembers = async ({ ctx, courseId, diff --git a/apps/web/graphql/courses/mutation.ts b/apps/web/graphql/courses/mutation.ts index bd6866d9b..bd1c90502 100644 --- a/apps/web/graphql/courses/mutation.ts +++ b/apps/web/graphql/courses/mutation.ts @@ -14,6 +14,7 @@ import { removeGroup, addGroup, updateGroup, + reorderGroups, updateCourseCertificateTemplate, } from "./logic"; import Filter from "./models/filter"; @@ -125,6 +126,21 @@ export default { ctx: context, }), }, + reorderGroups: { + type: types.courseType, + args: { + courseId: { + type: new GraphQLNonNull(GraphQLString), + }, + groupIds: { + type: new GraphQLNonNull( + new GraphQLList(new GraphQLNonNull(GraphQLString)), + ), + }, + }, + resolve: async (_: unknown, { courseId, groupIds }, context) => + reorderGroups({ courseId, groupIds, ctx: context }), + }, updateCourseCertificateTemplate: { type: types.certificateTemplateType, args: { diff --git a/packages/components-library/src/drag-and-drop.tsx b/packages/components-library/src/drag-and-drop.tsx index 79c257315..f2d58d68e 100644 --- a/packages/components-library/src/drag-and-drop.tsx +++ b/packages/components-library/src/drag-and-drop.tsx @@ -28,10 +28,12 @@ export function SortableItem({ id, Renderer, rendererProps, + disabled = false, }: { - id: number; + id: number | string; Renderer: any; rendererProps: Record; + disabled?: boolean; }) { const { attributes, @@ -40,7 +42,7 @@ export function SortableItem({ transform, transition, isDragging, - } = useSortable({ id: id }); + } = useSortable({ id: id, disabled }); const style = { transition, @@ -59,8 +61,10 @@ export function SortableItem({ >
@@ -74,10 +78,12 @@ const DragAndDrop = ({ items, onChange, Renderer, + disabled = false, }: { items: any; onChange: any; Renderer: any; + disabled?: boolean; }) => { const [data, setData] = useState(items); @@ -101,12 +107,16 @@ const DragAndDrop = ({ }), ); - const findPositionOfItems = (id: number) => - data.findIndex((item: { id: number }) => item.id === id); + const findPositionOfItems = (id: number | string) => + data.findIndex((item: { id: number | string }) => item.id === id); const handleDragEnd = (event: { active: any; over: any }) => { + if (disabled) { + return; + } const { active, over } = event; + if (!over) return; if (active.id === over.id) return; setData((data: any) => { const originalPos = findPositionOfItems(active.id); @@ -137,6 +147,7 @@ const DragAndDrop = ({ id={item.id} rendererProps={item} Renderer={Renderer} + disabled={disabled} /> ))} From aeae20e3291135a0cc3d4543b0eaded1935f960e Mon Sep 17 00:00:00 2001 From: Rajat Date: Thu, 26 Mar 2026 01:07:25 +0530 Subject: [PATCH 2/6] Cross section lesson drag-n-drop; Section d-n-d replaced with buttons; --- .../__tests__/layout-with-sidebar.test.tsx | 83 ++- .../__tests__/content-sections-board.test.tsx | 276 +++++++++ .../components/__tests__/helpers.test.ts | 48 ++ .../__tests__/lesson-list-item.test.tsx | 88 +++ .../components/content-section-card.tsx | 210 +++++++ .../components/content-sections-board.tsx | 513 +++++++++++++++++ .../[id]/content/components/helpers.ts | 80 +++ .../content/components/lesson-list-item.tsx | 94 ++++ .../multi-container-drag-and-drop.tsx | 374 +++++++++++++ .../components/section-lesson-list.tsx | 102 ++++ .../(sidebar)/product/[id]/content/page.tsx | 407 ++------------ .../courses/__tests__/move-lesson.test.ts | 523 ++++++++++++++++++ .../__tests__/update-group-metadata.test.ts | 91 +++ apps/web/graphql/courses/logic.ts | 103 +++- apps/web/graphql/courses/mutation.ts | 36 +- apps/web/next-env.d.ts | 2 +- apps/web/package.json | 265 ++++----- apps/web/ui-config/strings.ts | 2 + pnpm-lock.yaml | 44 +- 19 files changed, 2810 insertions(+), 531 deletions(-) create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/content-sections-board.test.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/helpers.test.ts create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/lesson-list-item.test.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/content-section-card.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/content-sections-board.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/helpers.ts create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/lesson-list-item.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/multi-container-drag-and-drop.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/section-lesson-list.tsx create mode 100644 apps/web/graphql/courses/__tests__/move-lesson.test.ts create mode 100644 apps/web/graphql/courses/__tests__/update-group-metadata.test.ts diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx index 31f698d49..1e5d76831 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx @@ -214,7 +214,7 @@ describe("generateSideBarItems", () => { status: true, type: Constants.dripType[1].split("-")[0].toUpperCase(), dateInUTC: new Date( - "2026-03-24T00:00:00.000Z", + "2099-03-24T00:00:00.000Z", ).getTime(), }, }, @@ -374,7 +374,7 @@ describe("generateSideBarItems", () => { status: true, type: Constants.dripType[1].split("-")[0].toUpperCase(), dateInUTC: new Date( - "2026-03-24T00:00:00.000Z", + "2099-03-24T00:00:00.000Z", ).getTime(), }, }, @@ -458,7 +458,7 @@ describe("generateSideBarItems", () => { ); expect(items[2].badge?.text).toBe("Mar 22, 2026"); - expect(items[2].badge?.description).toBe("Available on Mar 22, 2026"); + expect(items[2].badge?.description).toBe(""); }); it("uses purchase createdAt as the relative drip anchor when lastDripAt is absent", () => { @@ -648,4 +648,81 @@ describe("generateSideBarItems", () => { expect(items[3].badge?.text).toBe("3 days"); }); + + it("renders reordered lessons under the destination section in sidebar order", () => { + const course = { + title: "Course", + description: "", + featuredImage: undefined, + updatedAt: new Date().toISOString(), + creatorId: "creator-1", + slug: "test-course", + cost: 0, + courseId: "course-1", + tags: [], + paymentPlans: [], + defaultPaymentPlan: "", + firstLesson: "lesson-2", + groups: [ + { + id: "group-1", + name: "First Section", + lessons: [ + { + lessonId: "lesson-1", + title: "Text 1", + requiresEnrollment: false, + }, + ], + }, + { + id: "group-2", + name: "Second Section", + lessons: [ + { + lessonId: "lesson-2", + title: "Chapter 5 - Text 2", + requiresEnrollment: false, + }, + { + lessonId: "lesson-3", + title: "Text 3", + requiresEnrollment: false, + }, + ], + }, + ], + } as unknown as CourseFrontend; + + const profile = { + userId: "user-1", + purchases: [ + { + courseId: "course-1", + accessibleGroups: ["group-1", "group-2"], + }, + ], + } as unknown as Profile; + + const items = generateSideBarItems( + course, + profile, + "/course/test-course/course-1", + ); + + const firstSectionItems = items.find( + (item) => item.title === "First Section", + )?.items; + const secondSectionItems = items.find( + (item) => item.title === "Second Section", + )?.items; + + expect(firstSectionItems?.map((item) => item.title)).toEqual([ + "Text 1", + ]); + expect(secondSectionItems?.map((item) => item.title)).toEqual([ + "Chapter 5 - Text 2", + "Text 3", + ]); + }); }); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/content-sections-board.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/content-sections-board.test.tsx new file mode 100644 index 000000000..0ebfe1abe --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/content-sections-board.test.tsx @@ -0,0 +1,276 @@ +import React from "react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import ContentSectionsBoard from "../content-sections-board"; + +const toastMock = jest.fn(); +const reorderExecMock = jest.fn(); +const moveLessonExecMock = jest.fn(); + +let resolveMoveLesson: (() => void) | null = null; +let rejectMoveLesson: ((error: unknown) => void) | null = null; + +jest.mock("@courselit/components-library", () => ({ + useToast: () => ({ toast: toastMock }), +})); + +jest.mock("../multi-container-drag-and-drop", () => ({ + MultiContainerDragAndDrop: ({ + children, + onMove, + onDragStateChange, + }: any) => ( +
+ + + {children} +
+ ), +})); + +jest.mock("@courselit/utils", () => ({ + FetchBuilder: class { + payload: any; + setUrl() { + return this; + } + setPayload(payload: any) { + this.payload = payload; + return this; + } + setIsGraphQLEndpoint() { + return this; + } + build() { + const query = this.payload?.query ?? ""; + if (query.includes("moveLesson")) { + return { + exec: moveLessonExecMock, + }; + } + + return { + exec: reorderExecMock, + }; + } + }, +})); + +jest.mock("../content-section-card", () => { + return function MockContentSectionCard(props: any) { + return ( +
+
+ {(props.lessons ?? []) + .map((lesson: any) => lesson.title) + .join(",")} +
+ + +
+ ); + }; +}); + +describe("ContentSectionsBoard", () => { + beforeEach(() => { + toastMock.mockReset(); + reorderExecMock.mockReset().mockResolvedValue({}); + moveLessonExecMock.mockReset().mockImplementation( + () => + new Promise((resolve, reject) => { + resolveMoveLesson = () => resolve({}); + rejectMoveLesson = reject; + }), + ); + }); + + const sections = [ + { + id: "group-1", + name: "Group 1", + rank: 1000, + collapsed: false, + lessonsOrder: ["lesson-1"], + }, + { + id: "group-2", + name: "Group 2", + rank: 2000, + collapsed: false, + lessonsOrder: [], + }, + ] as any; + + const lessons = [ + { + lessonId: "lesson-1", + title: "Lesson 1", + type: "text", + groupId: "group-1", + published: true, + }, + ] as any; + + it("disables section move controls while moveLesson is in-flight", async () => { + const setOrderedSections = jest.fn(); + + render( + , + ); + + fireEvent.click(screen.getByTestId("start-lesson-drag")); + fireEvent.click(screen.getByTestId("end-lesson-drag")); + + await waitFor(() => + expect(screen.getByTestId("move-down-group-1")).toBeDisabled(), + ); + + fireEvent.click(screen.getByTestId("move-down-group-1")); + expect(reorderExecMock).not.toHaveBeenCalled(); + + await act(async () => { + resolveMoveLesson?.(); + }); + + await waitFor(() => + expect(screen.getByTestId("move-down-group-1")).not.toBeDisabled(), + ); + }); + + it("moves section down using reorderGroups mutation", async () => { + const setOrderedSections = jest.fn(); + + render( + , + ); + + fireEvent.click(screen.getByTestId("move-down-group-1")); + + expect(setOrderedSections).toHaveBeenCalledTimes(1); + expect(reorderExecMock).toHaveBeenCalledTimes(1); + }); + + it("rolls back optimistic lesson move when moveLesson fails", async () => { + render( + , + ); + + fireEvent.click(screen.getByTestId("start-lesson-drag")); + fireEvent.click(screen.getByTestId("end-lesson-drag")); + + await act(async () => { + rejectMoveLesson?.(new Error("Move failed")); + }); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "destructive", + }), + ), + ); + }); + + it("keeps moved lessons in destination section after section reorder", async () => { + moveLessonExecMock.mockResolvedValueOnce({}); + + const Harness = () => { + const [localSections, setLocalSections] = React.useState(sections); + return ( + + ); + }; + + render(); + + fireEvent.click(screen.getByTestId("start-lesson-drag")); + fireEvent.click(screen.getByTestId("end-lesson-drag")); + + await waitFor(() => + expect(screen.getByTestId("lessons-group-2")).toHaveTextContent( + "Lesson 1", + ), + ); + + fireEvent.click(screen.getByTestId("move-up-group-2")); + + await waitFor(() => + expect(screen.getByTestId("lessons-group-2")).toHaveTextContent( + "Lesson 1", + ), + ); + }); +}); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/helpers.test.ts b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/helpers.test.ts new file mode 100644 index 000000000..e38eaa2f3 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/helpers.test.ts @@ -0,0 +1,48 @@ +import { applyLessonMove, buildLessonMap } from "../helpers"; + +describe("content lesson helpers", () => { + it("builds lesson map by section order", () => { + const sections: any[] = [ + { + id: "group-1", + lessonsOrder: ["lesson-2", "lesson-1"], + }, + ]; + const lessons: any[] = [ + { lessonId: "lesson-1", groupId: "group-1", title: "L1" }, + { lessonId: "lesson-2", groupId: "group-1", title: "L2" }, + ]; + + const map = buildLessonMap(sections as any, lessons as any); + expect(map["group-1"].map((lesson) => lesson.lessonId)).toEqual([ + "lesson-2", + "lesson-1", + ]); + }); + + it("moves lessons across sections optimistically", () => { + const current: any = { + "group-1": [ + { lessonId: "lesson-1", groupId: "group-1", title: "L1" }, + { lessonId: "lesson-2", groupId: "group-1", title: "L2" }, + ], + "group-2": [], + }; + + const next = applyLessonMove({ + current, + lessonId: "lesson-2", + sourceSectionId: "group-1", + destinationSectionId: "group-2", + destinationIndex: 0, + }); + + expect(next["group-1"].map((lesson: any) => lesson.lessonId)).toEqual([ + "lesson-1", + ]); + expect(next["group-2"].map((lesson: any) => lesson.lessonId)).toEqual([ + "lesson-2", + ]); + expect(next["group-2"][0].groupId).toBe("group-2"); + }); +}); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/lesson-list-item.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/lesson-list-item.test.tsx new file mode 100644 index 000000000..f3a1c9813 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/lesson-list-item.test.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import LessonListItem from "../lesson-list-item"; +import type { useMultiContainerSortableItem } from "../multi-container-drag-and-drop"; + +jest.mock("next/navigation", () => ({ + useRouter: () => ({ + push: jest.fn(), + }), +})); + +describe("LessonListItem", () => { + it("disables lesson drag handle when disabled is true", () => { + render( + ["attributes"] + } + listeners={ + {} as ReturnType< + typeof useMultiContainerSortableItem + >["listeners"] + } + setNodeRef={ + jest.fn() as ReturnType< + typeof useMultiContainerSortableItem + >["setNodeRef"] + } + style={{}} + />, + ); + + expect(screen.getByTestId("lesson-drag-handle")).toBeDisabled(); + }); + + it("keeps lesson drag handle enabled when disabled is false", () => { + render( + ["attributes"] + } + listeners={ + {} as ReturnType< + typeof useMultiContainerSortableItem + >["listeners"] + } + setNodeRef={ + jest.fn() as ReturnType< + typeof useMultiContainerSortableItem + >["setNodeRef"] + } + style={{}} + />, + ); + + expect(screen.getByTestId("lesson-drag-handle")).not.toBeDisabled(); + }); +}); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/content-section-card.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/content-section-card.tsx new file mode 100644 index 000000000..ac1a776dc --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/content-section-card.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + ChevronUp, + ChevronDown, + ChevronRight, + Droplets, + MoreHorizontal, + Plus, +} from "lucide-react"; +import Link from "next/link"; +import { Constants, Group } from "@courselit/common-models"; +import { + BUTTON_NEW_LESSON_TEXT, + BUTTON_NEW_LESSON_TEXT_DOWNLOAD, + EDIT_SECTION_HEADER, + BUTTON_MOVE_SECTION_UP, + BUTTON_MOVE_SECTION_DOWN, +} from "@ui-config/strings"; +import SectionLessonList from "./section-lesson-list"; +import { LessonSummary } from "./helpers"; + +export default function ContentSectionCard({ + section, + lessons, + lessonInsertionCueIndex, + collapsed, + sectionMenuOpenId, + productType, + totalGroups, + productId, + lessonDragDisabled, + canMoveUp, + canMoveDown, + sectionMoveDisabled, + onMoveUp, + onMoveDown, + onSectionMenuOpenChange, + onToggleCollapse, + onRequestDelete, +}: { + section: Group; + lessons: LessonSummary[]; + lessonInsertionCueIndex?: number | null; + collapsed: boolean; + sectionMenuOpenId: string | null; + productType?: string; + totalGroups: number; + productId: string; + lessonDragDisabled: boolean; + canMoveUp: boolean; + canMoveDown: boolean; + sectionMoveDisabled: boolean; + onMoveUp: () => void; + onMoveDown: () => void; + onSectionMenuOpenChange: (sectionId: string | null) => void; + onToggleCollapse: (sectionId: string) => void; + onRequestDelete: (item: { id: string; title: string }) => void; +}) { + const isSingleDownloadGroup = + productType?.toLowerCase() === Constants.CourseType.DOWNLOAD && + totalGroups === 1; + + return ( +
+
+
+ +
+

+ {section.name} +

+ {section.drip?.status && ( + + + + + + +

+ This section has scheduled release +

+
+
+
+ )} +
+
+
+ + + + onSectionMenuOpenChange(open ? section.id : null) + } + > + + + + + + + {EDIT_SECTION_HEADER} + + + {!isSingleDownloadGroup && ( + <> + + + onRequestDelete({ + id: section.id, + title: section.name, + }) + } + className="text-red-600" + > + Delete Section + + + )} + + +
+
+ {!collapsed && ( +
+ + +
+ )} +
+ ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/content-sections-board.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/content-sections-board.tsx new file mode 100644 index 000000000..b91da09ed --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/content-sections-board.tsx @@ -0,0 +1,513 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Group } from "@courselit/common-models"; +import { useToast } from "@courselit/components-library"; +import { FetchBuilder } from "@courselit/utils"; +import { TOAST_TITLE_ERROR } from "@ui-config/strings"; +import ContentSectionCard from "./content-section-card"; +import { + MultiContainerDragAndDrop, + MultiContainerMoveEvent, + MultiContainerSnapshot, +} from "./multi-container-drag-and-drop"; +import { + applyLessonMove, + buildLessonMap, + LessonMap, + LessonSummary, + sortLessonsForSection, +} from "./helpers"; + +const arrayMove = (items: T[], oldIndex: number, newIndex: number): T[] => { + const next = [...items]; + const [moved] = next.splice(oldIndex, 1); + if (!moved) { + return items; + } + next.splice(newIndex, 0, moved); + return next; +}; + +const findLessonLocation = ( + map: LessonMap, + lessonId: string, +): { sectionId: string; index: number } | null => { + for (const [sectionId, sectionLessons] of Object.entries(map)) { + const index = sectionLessons.findIndex( + (lesson) => lesson.lessonId === lessonId, + ); + if (index !== -1) { + return { + sectionId, + index, + }; + } + } + + return null; +}; + +const normalizeDestinationIndex = ({ + map, + sourceSectionId, + destinationSectionId, + destinationIndex, +}: { + map: LessonMap; + sourceSectionId: string; + destinationSectionId: string; + destinationIndex: number; +}) => { + const sourceLessons = map[sourceSectionId] ?? []; + const destinationLessons = map[destinationSectionId] ?? []; + const maxIndex = + sourceSectionId === destinationSectionId + ? Math.max(sourceLessons.length - 1, 0) + : destinationLessons.length; + + return Math.min(Math.max(destinationIndex, 0), maxIndex); +}; + +export default function ContentSectionsBoard({ + orderedSections, + setOrderedSections, + lessons, + courseId, + productId, + productType, + address, + onRequestDelete, +}: { + orderedSections: Group[]; + setOrderedSections: React.Dispatch>; + lessons: LessonSummary[]; + courseId: string; + productId: string; + productType?: string; + address: string; + onRequestDelete: (item: { id: string; title: string }) => void; +}) { + const { toast } = useToast(); + const [collapsedSections, setCollapsedSections] = useState([]); + const [sectionMenuOpenId, setSectionMenuOpenId] = useState( + null, + ); + const [isReordering, setIsReordering] = useState(false); + const [isMovingLesson, setIsMovingLesson] = useState(false); + const [lessonMap, setLessonMap] = useState({}); + const [activeLessonDrag, setActiveLessonDrag] = useState<{ + lessonId: string; + sourceSectionId: string; + } | null>(null); + const [focusedSectionId, setFocusedSectionId] = useState( + null, + ); + const [recentlyMovedSectionId, setRecentlyMovedSectionId] = useState< + string | null + >(null); + const sectionRefs = useRef>({}); + const [lessonHoverPreview, setLessonHoverPreview] = useState<{ + lessonId: string; + destinationSectionId: string; + destinationIndex: number; + } | null>(null); + + useEffect(() => { + setLessonMap(buildLessonMap(orderedSections, lessons)); + }, [lessons]); + + useEffect(() => { + setLessonMap((current) => { + const next = orderedSections.reduce((acc, section) => { + acc[section.id] = + current[section.id] ?? + sortLessonsForSection(lessons, section); + return acc; + }, {} as LessonMap); + + return next; + }); + }, [orderedSections, lessons]); + + useEffect(() => { + if (!focusedSectionId) { + return; + } + + const sectionNode = sectionRefs.current[focusedSectionId]; + if (!sectionNode) { + return; + } + + requestAnimationFrame(() => { + if (typeof sectionNode.scrollIntoView === "function") { + sectionNode.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }); + setFocusedSectionId(null); + }, [focusedSectionId, orderedSections]); + + useEffect(() => { + if (!recentlyMovedSectionId) { + return; + } + + const timerId = window.setTimeout(() => { + setRecentlyMovedSectionId(null); + }, 900); + + return () => window.clearTimeout(timerId); + }, [recentlyMovedSectionId]); + + const disabled = isReordering || isMovingLesson; + const sectionMoveDisabled = disabled || !!activeLessonDrag; + + const toggleSectionCollapse = (sectionId: string) => { + setCollapsedSections((prev) => + prev.includes(sectionId) + ? prev.filter((id) => id !== sectionId) + : [...prev, sectionId], + ); + }; + + const reorderGroups = async ( + groupIds: string[], + fallbackSections: Group[], + ) => { + const mutation = ` + mutation ReorderGroups($courseId: String!, $groupIds: [String!]!) { + course: reorderGroups(courseId: $courseId, groupIds: $groupIds) { + courseId + groups { + id + rank + } + } + } + `; + + const fetch = new FetchBuilder() + .setUrl(`${address}/api/graph`) + .setPayload({ + query: mutation, + variables: { + courseId, + groupIds, + }, + }) + .setIsGraphQLEndpoint(true) + .build(); + + setIsReordering(true); + try { + await fetch.exec(); + } catch (err: any) { + setOrderedSections(fallbackSections); + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setIsReordering(false); + } + }; + + const moveLesson = async ({ + lessonId, + destinationGroupId, + destinationIndex, + rollbackSnapshot, + }: { + lessonId: string; + destinationGroupId: string; + destinationIndex: number; + rollbackSnapshot: LessonMap; + }) => { + const mutation = ` + mutation MoveLesson( + $courseId: String!, + $lessonId: String!, + $destinationGroupId: String!, + $destinationIndex: Int! + ) { + course: moveLesson( + courseId: $courseId, + lessonId: $lessonId, + destinationGroupId: $destinationGroupId, + destinationIndex: $destinationIndex + ) { + courseId + } + } + `; + + const fetch = new FetchBuilder() + .setUrl(`${address}/api/graph`) + .setPayload({ + query: mutation, + variables: { + courseId, + lessonId, + destinationGroupId, + destinationIndex, + }, + }) + .setIsGraphQLEndpoint(true) + .build(); + + setIsMovingLesson(true); + try { + await fetch.exec(); + } catch (err: any) { + setLessonMap(rollbackSnapshot); + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setIsMovingLesson(false); + setActiveLessonDrag(null); + setLessonHoverPreview(null); + } + }; + + const handleLessonOver = ({ + itemId, + destinationContainerId, + destinationIndex, + }: MultiContainerMoveEvent) => { + if ( + disabled || + !activeLessonDrag || + activeLessonDrag.lessonId !== itemId + ) { + return; + } + + if (activeLessonDrag.sourceSectionId === destinationContainerId) { + setLessonHoverPreview((prev) => (prev ? null : prev)); + return; + } + + const destinationLessons = lessonMap[destinationContainerId] ?? []; + const safeDestinationIndex = Math.min( + Math.max(destinationIndex, 0), + destinationLessons.length, + ); + + setLessonHoverPreview((prev) => { + if ( + prev && + prev.lessonId === itemId && + prev.destinationSectionId === destinationContainerId && + prev.destinationIndex === safeDestinationIndex + ) { + return prev; + } + + return { + lessonId: itemId, + destinationSectionId: destinationContainerId, + destinationIndex: safeDestinationIndex, + }; + }); + }; + + const handleLessonMove = ({ + itemId, + sourceContainerId, + sourceIndex, + destinationContainerId, + destinationIndex, + }: MultiContainerMoveEvent) => { + if (disabled) { + return; + } + + setLessonHoverPreview(null); + const currentLocation = findLessonLocation(lessonMap, itemId) ?? { + sectionId: sourceContainerId, + index: sourceIndex, + }; + const safeDestinationIndex = normalizeDestinationIndex({ + map: lessonMap, + sourceSectionId: currentLocation.sectionId, + destinationSectionId: destinationContainerId, + destinationIndex, + }); + + if ( + currentLocation.sectionId === destinationContainerId && + currentLocation.index === safeDestinationIndex + ) { + return; + } + + const rollbackSnapshot = lessonMap; + setLessonMap((current) => { + const currentLocation = findLessonLocation(current, itemId); + if (!currentLocation) { + return current; + } + + return applyLessonMove({ + current, + lessonId: itemId, + sourceSectionId: currentLocation.sectionId, + destinationSectionId: destinationContainerId, + destinationIndex: safeDestinationIndex, + }); + }); + + moveLesson({ + lessonId: itemId, + destinationGroupId: destinationContainerId, + destinationIndex: safeDestinationIndex, + rollbackSnapshot, + }); + }; + + const moveSection = (sectionId: string, offset: number) => { + if (sectionMoveDisabled) { + return; + } + + const sourceIndex = orderedSections.findIndex( + (section) => section.id === sectionId, + ); + if (sourceIndex < 0) { + return; + } + + const destinationIndex = sourceIndex + offset; + if ( + destinationIndex < 0 || + destinationIndex >= orderedSections.length + ) { + return; + } + + const fallbackSections = [...orderedSections]; + const nextSections = arrayMove( + orderedSections, + sourceIndex, + destinationIndex, + ); + setOrderedSections(nextSections); + setFocusedSectionId(sectionId); + setRecentlyMovedSectionId(sectionId); + reorderGroups( + nextSections.map((section) => section.id), + fallbackSections, + ); + }; + + const lessonContainers = useMemo( + () => + orderedSections.map((section) => ({ + containerId: section.id, + itemIds: (lessonMap[section.id] ?? []).map( + (lesson) => lesson.lessonId, + ), + })), + [orderedSections, lessonMap], + ); + + const lessonLookup = useMemo(() => { + const map = new Map(); + lessons.forEach((lesson) => { + map.set(lesson.lessonId, lesson); + }); + return map; + }, [lessons]); + + return ( + { + if (drag) { + setLessonHoverPreview(null); + setActiveLessonDrag({ + lessonId: drag.itemId, + sourceSectionId: drag.sourceContainerId, + }); + return; + } + + setActiveLessonDrag(null); + setLessonHoverPreview(null); + }} + renderDragOverlay={(itemId) => { + const item = lessonLookup.get(itemId); + if (!item) { + return null; + } + + return ( +
+ + {item.title} + +
+ ); + }} + > + {orderedSections.map((section, index) => { + const lessonInsertionCueIndex = + lessonHoverPreview && + activeLessonDrag && + lessonHoverPreview.lessonId === activeLessonDrag.lessonId && + lessonHoverPreview.destinationSectionId === section.id && + activeLessonDrag.sourceSectionId !== section.id + ? lessonHoverPreview.destinationIndex + : null; + + return ( +
{ + sectionRefs.current[section.id] = node; + }} + className={`scroll-mt-24 rounded-md transition-colors duration-300 ${ + recentlyMovedSectionId === section.id + ? "bg-primary/10 ring-1 ring-primary/40 motion-safe:animate-pulse" + : "" + }`} + > + 0} + canMoveDown={index < orderedSections.length - 1} + sectionMoveDisabled={sectionMoveDisabled} + onMoveUp={() => moveSection(section.id, -1)} + onMoveDown={() => moveSection(section.id, 1)} + onSectionMenuOpenChange={setSectionMenuOpenId} + onToggleCollapse={toggleSectionCollapse} + onRequestDelete={(item) => { + setSectionMenuOpenId(null); + onRequestDelete(item); + }} + /> +
+ ); + })} +
+ ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/helpers.ts b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/helpers.ts new file mode 100644 index 000000000..dae671221 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/helpers.ts @@ -0,0 +1,80 @@ +import { Group } from "@courselit/common-models"; +import { ProductWithAdminProps } from "@/hooks/use-product"; + +export type LessonSummary = NonNullable< + ProductWithAdminProps["lessons"] +>[number]; + +export type LessonMap = Record; + +export const sortLessonsForSection = ( + lessons: LessonSummary[], + section: Group, +): LessonSummary[] => { + return [...lessons] + .filter((lesson) => lesson.groupId === section.id) + .sort( + (a, b) => + (section.lessonsOrder ?? []).indexOf(a.lessonId) - + (section.lessonsOrder ?? []).indexOf(b.lessonId), + ); +}; + +export const buildLessonMap = ( + sections: Group[], + lessons: LessonSummary[], +): LessonMap => { + return sections.reduce((acc, section) => { + acc[section.id] = sortLessonsForSection(lessons, section); + return acc; + }, {} as LessonMap); +}; + +export const applyLessonMove = ({ + current, + lessonId, + sourceSectionId, + destinationSectionId, + destinationIndex, +}: { + current: LessonMap; + lessonId: string; + sourceSectionId: string; + destinationSectionId: string; + destinationIndex: number; +}): LessonMap => { + const sourceLessons = [...(current[sourceSectionId] ?? [])]; + const destinationLessons = + sourceSectionId === destinationSectionId + ? sourceLessons + : [...(current[destinationSectionId] ?? [])]; + + const sourceIndex = sourceLessons.findIndex( + (lesson) => lesson.lessonId === lessonId, + ); + if (sourceIndex === -1) { + return current; + } + + const [movedLesson] = sourceLessons.splice(sourceIndex, 1); + if (!movedLesson) { + return current; + } + + const updatedLesson = { + ...movedLesson, + groupId: destinationSectionId, + }; + + const safeDestinationIndex = Math.min( + Math.max(destinationIndex, 0), + destinationLessons.length, + ); + destinationLessons.splice(safeDestinationIndex, 0, updatedLesson); + + return { + ...current, + [sourceSectionId]: sourceLessons, + [destinationSectionId]: destinationLessons, + }; +}; diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/lesson-list-item.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/lesson-list-item.tsx new file mode 100644 index 000000000..0b53794a0 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/lesson-list-item.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { ChevronRight, FileText, HelpCircle, Video } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { DragHandle } from "@courselit/icons"; +import { PRODUCT_STATUS_DRAFT } from "@ui-config/strings"; +import { CSSProperties } from "react"; +import { LessonSummary } from "./helpers"; +import type { useMultiContainerSortableItem } from "./multi-container-drag-and-drop"; + +function LessonTypeIcon({ type }: { type: string }) { + switch (type) { + case "video": + return