diff --git a/AGENTS.md b/AGENTS.md
index 686512709..8c682be75 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,6 +16,7 @@
- Use **Cursors** (`.cursor()`) to stream data from MongoDB, ensuring the script remains memory-efficient regardless of dataset size.
- Use **Batching** with `bulkWrite` (e.g., batches of 500) to maximize performance and minimize network roundtrips.
- Ensure **Idempotency** (safe to re-run) by using upserts or `$setOnInsert` where applicable.
+- When making changes to the structure of the Course, consider how it affects its representation on its public page (`apps/web/app/(with-contexts)/(with-layout)/p/[id]/page.tsx`) and the course viewer (`apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx`).
### Workspace map (core modules):
@@ -48,6 +49,7 @@
- Always add or update test when introducing changes to `apps/web/graphql` folder, even if nobody asked.
- Run `pnpm test` to run the tests.
- Fix any test or type errors until the whole suite is green.
+- Refrain from creating new files when adding tests in `apps/web/graphql` subdirectories. Re-use `logic.test.ts` files for adding new test suites i.e. describe blocks.
## PR instructions
diff --git a/apps/docs/public/assets/products/lesson-reordering.png b/apps/docs/public/assets/products/lesson-reordering.png
new file mode 100644
index 000000000..8756ed7f9
Binary files /dev/null and b/apps/docs/public/assets/products/lesson-reordering.png differ
diff --git a/apps/docs/public/assets/products/section-reordering.png b/apps/docs/public/assets/products/section-reordering.png
new file mode 100644
index 000000000..b6fbfa7e2
Binary files /dev/null and b/apps/docs/public/assets/products/section-reordering.png differ
diff --git a/apps/docs/src/pages/en/courses/add-content.md b/apps/docs/src/pages/en/courses/add-content.md
index c1a4b7671..9951c6eae 100644
--- a/apps/docs/src/pages/en/courses/add-content.md
+++ b/apps/docs/src/pages/en/courses/add-content.md
@@ -6,7 +6,7 @@ layout: ../../../layouts/MainLayout.astro
CourseLit uses the concept of a `Lesson`. It is very similar to what we generally see in books, i.e., a large piece of information is divided into smaller chunks called lessons.
-Similarly, you can break down your course into `Lessons` and group the lessons into [Sections](/en/products/section).
+Similarly, you can break down your course into `Lessons` and group the lessons into [Sections](/en/courses/section).
## Sections
diff --git a/apps/docs/src/pages/en/courses/section.md b/apps/docs/src/pages/en/courses/section.md
index d6d03ed1e..0061285a1 100644
--- a/apps/docs/src/pages/en/courses/section.md
+++ b/apps/docs/src/pages/en/courses/section.md
@@ -46,6 +46,18 @@ Here’s how sections look in various parts of the platform.

+## Rearranging Sections
+
+You can move sections up or down as you like. Click the chevron up or down buttons to move a section.
+
+
+
+## Moving a Lesson Between Sections
+
+Use the drag-and-drop handles on the left side of a lesson's listing to move it to any section.
+
+
+
## Drip a Section
You can release a section on a **specific date** or **after a certain number of days have elapsed since the time a student enrolls**.
@@ -76,6 +88,8 @@ If drip configuration is enabled for a section, a student won't be able to acces
4. Select the number of days.
5. Click `Continue` to save it.
+> Rearranging a section with drip enabled may affect its drip schedule; use caution.
+
### Notify Users When a Section Has Dripped
1. Click on the `Email Notification` checkbox.
@@ -98,7 +112,7 @@ On the course viewer, the customer will see the clock icon against the section n
2. Click `Delete` on the confirmation dialog.
-> A section must be empty (i.e., have no lessons attached to it) in order to be deleted.
+> A section must be empty (i.e., have no lessons attached) before it can be deleted. Move any lessons to another section to make it empty.
## Next Step
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..0e298e6b2 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
@@ -1,4 +1,3 @@
-import { sortCourseGroups } from "@ui-lib/utils";
import { Course, Group, Lesson } from "@courselit/common-models";
import { FetchBuilder } from "@courselit/utils";
@@ -92,15 +91,16 @@ export const getProduct = async (
export function formatCourse(
post: Course & { lessons: Lesson[]; firstLesson: string; groups: Group[] },
): CourseFrontend {
- for (const group of sortCourseGroups(post as Course)) {
- (group as GroupWithLessons).lessons = post.lessons
+ const groupsWithLessons = post.groups.map((group) => ({
+ ...group,
+ lessons: post.lessons
.filter((lesson: Lesson) => lesson.groupId === group.id)
.sort(
(a: any, b: any) =>
group.lessonsOrder?.indexOf(a.lessonId) -
group.lessonsOrder?.indexOf(b.lessonId),
- );
- }
+ ),
+ }));
return {
title: post.title,
@@ -111,7 +111,7 @@ export function formatCourse(
slug: post.slug,
cost: post.cost,
courseId: post.courseId,
- groups: post.groups as GroupWithLessons[],
+ groups: groupsWithLessons 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..a3aa6fb53
--- /dev/null
+++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts
@@ -0,0 +1,67 @@
+import { formatCourse } from "../helpers";
+
+describe("course helpers formatCourse", () => {
+ const makeCourse = () =>
+ ({
+ 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;
+
+ it("preserves group order from the backend response", () => {
+ const formatted = formatCourse(makeCourse());
+
+ expect(formatted.groups.map((group) => group.id)).toEqual([
+ "group-2",
+ "group-1",
+ ]);
+ });
+
+ it("sorts lessons within each group by lessonsOrder", () => {
+ const formatted = formatCourse(makeCourse());
+
+ expect(
+ formatted.groups[0].lessons.map((lesson) => lesson.lessonId),
+ ).toEqual(["lesson-3", "lesson-2"]);
+ });
+});
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)/course/[slug]/[id]/helpers.ts b/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts
index ea139709d..0e298e6b2 100644
--- a/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts
+++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts
@@ -1,4 +1,3 @@
-import { sortCourseGroups } from "@ui-lib/utils";
import { Course, Group, Lesson } from "@courselit/common-models";
import { FetchBuilder } from "@courselit/utils";
@@ -92,15 +91,16 @@ export const getProduct = async (
export function formatCourse(
post: Course & { lessons: Lesson[]; firstLesson: string; groups: Group[] },
): CourseFrontend {
- for (const group of sortCourseGroups(post as Course)) {
- (group as GroupWithLessons).lessons = post.lessons
+ const groupsWithLessons = post.groups.map((group) => ({
+ ...group,
+ lessons: post.lessons
.filter((lesson: Lesson) => lesson.groupId === group.id)
.sort(
(a: any, b: any) =>
group.lessonsOrder?.indexOf(a.lessonId) -
group.lessonsOrder?.indexOf(b.lessonId),
- );
- }
+ ),
+ }));
return {
title: post.title,
@@ -111,7 +111,7 @@ export function formatCourse(
slug: post.slug,
cost: post.cost,
courseId: post.courseId,
- groups: post.groups as GroupWithLessons[],
+ groups: groupsWithLessons as GroupWithLessons[],
tags: post.tags,
firstLesson: post.firstLesson,
paymentPlans: post.paymentPlans,
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 ;
+ case "text":
+ return ;
+ case "quiz":
+ return ;
+ default:
+ return null;
+ }
+}
+
+export default function LessonListItem({
+ lesson,
+ sectionId,
+ productId,
+ disabled,
+ isDragging,
+ attributes,
+ listeners,
+ setNodeRef,
+ style,
+}: {
+ lesson: LessonSummary;
+ sectionId: string;
+ productId: string;
+ disabled: boolean;
+ isDragging: boolean;
+ attributes: ReturnType["attributes"];
+ listeners: ReturnType["listeners"];
+ setNodeRef: ReturnType["setNodeRef"];
+ style: CSSProperties;
+}) {
+ const router = useRouter();
+
+ return (
+
+
+
+
+ router.push(
+ `/dashboard/product/${productId}/content/section/${sectionId}/lesson?id=${lesson.lessonId}`,
+ )
+ }
+ >
+
+
+
+ {lesson.title}
+
+
+
+ {!lesson.published && (
+
+ {PRODUCT_STATUS_DRAFT}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/multi-container-drag-and-drop.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/multi-container-drag-and-drop.tsx
new file mode 100644
index 000000000..04485eefd
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/multi-container-drag-and-drop.tsx
@@ -0,0 +1,374 @@
+"use client";
+
+import {
+ closestCorners,
+ DndContext,
+ DragEndEvent,
+ DragOverEvent,
+ DragOverlay,
+ DragStartEvent,
+ KeyboardSensor,
+ PointerSensor,
+ useDroppable,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import {
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { CSSProperties, ReactNode, useMemo, useRef, useState } from "react";
+
+const ITEM_PREFIX = "mcdnd:item:";
+const CONTAINER_PREFIX = "mcdnd:container:";
+
+const getItemDndId = (itemId: string) => `${ITEM_PREFIX}${itemId}`;
+const getContainerDndId = (containerId: string) =>
+ `${CONTAINER_PREFIX}${containerId}`;
+
+const parseItemDndId = (value: string | number | null): string | null =>
+ typeof value === "string" && value.startsWith(ITEM_PREFIX)
+ ? value.slice(ITEM_PREFIX.length)
+ : null;
+
+const parseContainerDndId = (value: string | number | null): string | null =>
+ typeof value === "string" && value.startsWith(CONTAINER_PREFIX)
+ ? value.slice(CONTAINER_PREFIX.length)
+ : null;
+
+export type MultiContainerSnapshot = {
+ containerId: string;
+ itemIds: string[];
+};
+
+export type MultiContainerMoveEvent = {
+ itemId: string;
+ sourceContainerId: string;
+ sourceIndex: number;
+ destinationContainerId: string;
+ destinationIndex: number;
+};
+
+const findItemLocation = (
+ containers: MultiContainerSnapshot[],
+ itemId: string,
+): { containerId: string; index: number } | null => {
+ for (const container of containers) {
+ const index = container.itemIds.indexOf(itemId);
+ if (index !== -1) {
+ return {
+ containerId: container.containerId,
+ index,
+ };
+ }
+ }
+
+ return null;
+};
+
+export function MultiContainerDragAndDrop({
+ containers,
+ disabled = false,
+ onMove,
+ onOver,
+ onDragStateChange,
+ renderDragOverlay,
+ children,
+}: {
+ containers: MultiContainerSnapshot[];
+ disabled?: boolean;
+ onMove: (event: MultiContainerMoveEvent) => void;
+ onOver?: (event: MultiContainerMoveEvent) => void;
+ onDragStateChange?: (
+ drag: {
+ itemId: string;
+ sourceContainerId: string;
+ } | null,
+ ) => void;
+ renderDragOverlay?: (itemId: string) => ReactNode;
+ children: ReactNode;
+}) {
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 6,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+ const [activeItemId, setActiveItemId] = useState(null);
+ const dragStartLocationRef = useRef<{
+ containerId: string;
+ index: number;
+ } | null>(null);
+
+ const containerLookup = useMemo(() => {
+ const map = new Map();
+ for (const container of containers) {
+ container.itemIds.forEach((itemId, index) => {
+ map.set(itemId, { containerId: container.containerId, index });
+ });
+ }
+ return map;
+ }, [containers]);
+
+ const handleDragStart = (event: DragStartEvent) => {
+ if (disabled) {
+ return;
+ }
+
+ const itemId = parseItemDndId(event.active.id);
+ if (!itemId) {
+ return;
+ }
+
+ const location = containerLookup.get(itemId);
+ if (!location) {
+ return;
+ }
+
+ setActiveItemId(itemId);
+ dragStartLocationRef.current = {
+ containerId: location.containerId,
+ index: location.index,
+ };
+ onDragStateChange?.({
+ itemId,
+ sourceContainerId: location.containerId,
+ });
+ };
+
+ const clearDragState = () => {
+ setActiveItemId(null);
+ dragStartLocationRef.current = null;
+ onDragStateChange?.(null);
+ };
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const draggedItemId = parseItemDndId(event.active.id);
+ if (!draggedItemId || !event.over) {
+ clearDragState();
+ return;
+ }
+
+ const source = findItemLocation(containers, draggedItemId);
+ const effectiveSource = dragStartLocationRef.current ?? source;
+ if (!effectiveSource) {
+ clearDragState();
+ return;
+ }
+
+ const overItemId = parseItemDndId(event.over.id);
+ if (overItemId) {
+ const destination = findItemLocation(containers, overItemId);
+ if (!destination) {
+ clearDragState();
+ return;
+ }
+
+ if (
+ effectiveSource.containerId !== destination.containerId ||
+ effectiveSource.index !== destination.index
+ ) {
+ onMove({
+ itemId: draggedItemId,
+ sourceContainerId: effectiveSource.containerId,
+ sourceIndex: effectiveSource.index,
+ destinationContainerId: destination.containerId,
+ destinationIndex: destination.index,
+ });
+ }
+
+ clearDragState();
+ return;
+ }
+
+ const overContainerId = parseContainerDndId(event.over.id);
+ if (!overContainerId) {
+ clearDragState();
+ return;
+ }
+
+ const destinationContainer = containers.find(
+ (container) => container.containerId === overContainerId,
+ );
+ if (!destinationContainer) {
+ clearDragState();
+ return;
+ }
+
+ const destinationIndex = destinationContainer.itemIds.length;
+ if (
+ effectiveSource.containerId !== overContainerId ||
+ effectiveSource.index !== destinationIndex
+ ) {
+ onMove({
+ itemId: draggedItemId,
+ sourceContainerId: effectiveSource.containerId,
+ sourceIndex: effectiveSource.index,
+ destinationContainerId: overContainerId,
+ destinationIndex,
+ });
+ }
+
+ clearDragState();
+ };
+
+ const handleDragOver = (event: DragOverEvent) => {
+ if (!onOver) {
+ return;
+ }
+
+ const draggedItemId = parseItemDndId(event.active.id);
+ if (!draggedItemId || !event.over) {
+ return;
+ }
+
+ const source = findItemLocation(containers, draggedItemId);
+ if (!source) {
+ return;
+ }
+
+ const overItemId = parseItemDndId(event.over.id);
+ if (overItemId) {
+ const destination = findItemLocation(containers, overItemId);
+ if (!destination) {
+ return;
+ }
+
+ if (
+ source.containerId !== destination.containerId ||
+ source.index !== destination.index
+ ) {
+ onOver({
+ itemId: draggedItemId,
+ sourceContainerId: source.containerId,
+ sourceIndex: source.index,
+ destinationContainerId: destination.containerId,
+ destinationIndex: destination.index,
+ });
+ }
+
+ return;
+ }
+
+ const overContainerId = parseContainerDndId(event.over.id);
+ if (!overContainerId) {
+ return;
+ }
+
+ const destinationContainer = containers.find(
+ (container) => container.containerId === overContainerId,
+ );
+ if (!destinationContainer) {
+ return;
+ }
+
+ const destinationIndex = destinationContainer.itemIds.length;
+ if (
+ source.containerId !== overContainerId ||
+ source.index !== destinationIndex
+ ) {
+ onOver({
+ itemId: draggedItemId,
+ sourceContainerId: source.containerId,
+ sourceIndex: source.index,
+ destinationContainerId: overContainerId,
+ destinationIndex,
+ });
+ }
+ };
+
+ return (
+
+ {children}
+
+ {activeItemId && renderDragOverlay
+ ? renderDragOverlay(activeItemId)
+ : null}
+
+
+ );
+}
+
+export function MultiContainerSortableList({
+ containerId,
+ itemIds,
+ children,
+}: {
+ containerId: string;
+ itemIds: string[];
+ children: ReactNode;
+}) {
+ return (
+ getItemDndId(itemId))}
+ strategy={verticalListSortingStrategy}
+ >
+ {children}
+
+ );
+}
+
+export function useMultiContainerDroppable({
+ containerId,
+ disabled,
+}: {
+ containerId: string;
+ disabled?: boolean;
+}) {
+ return useDroppable({
+ id: getContainerDndId(containerId),
+ disabled,
+ });
+}
+
+export function useMultiContainerSortableItem({
+ itemId,
+ disabled,
+}: {
+ itemId: string;
+ disabled?: boolean;
+}): {
+ attributes: ReturnType["attributes"];
+ listeners: ReturnType["listeners"];
+ setNodeRef: ReturnType["setNodeRef"];
+ isDragging: boolean;
+ style: CSSProperties;
+} {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({
+ id: getItemDndId(itemId),
+ disabled,
+ });
+
+ return {
+ attributes,
+ listeners,
+ setNodeRef,
+ isDragging,
+ style: {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ },
+ };
+}
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/section-lesson-list.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/section-lesson-list.tsx
new file mode 100644
index 000000000..423ffd439
--- /dev/null
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/section-lesson-list.tsx
@@ -0,0 +1,102 @@
+"use client";
+
+import { Fragment } from "react";
+import {
+ MultiContainerSortableList,
+ useMultiContainerDroppable,
+ useMultiContainerSortableItem,
+} from "./multi-container-drag-and-drop";
+import LessonListItem from "./lesson-list-item";
+import { LessonSummary } from "./helpers";
+
+function SortableLessonRow({
+ lesson,
+ sectionId,
+ productId,
+ disabled,
+}: {
+ lesson: LessonSummary;
+ sectionId: string;
+ productId: string;
+ disabled: boolean;
+}) {
+ const { attributes, listeners, setNodeRef, isDragging, style } =
+ useMultiContainerSortableItem({
+ itemId: lesson.lessonId,
+ disabled,
+ });
+
+ return (
+
+ );
+}
+
+export default function SectionLessonList({
+ sectionId,
+ productId,
+ lessons,
+ disabled,
+ insertionCueIndex,
+}: {
+ sectionId: string;
+ productId: string;
+ lessons: LessonSummary[];
+ disabled: boolean;
+ insertionCueIndex?: number | null;
+}) {
+ const { setNodeRef } = useMultiContainerDroppable({
+ containerId: sectionId,
+ disabled,
+ });
+ const safeInsertionCueIndex =
+ typeof insertionCueIndex === "number"
+ ? Math.min(Math.max(insertionCueIndex, 0), lessons.length)
+ : null;
+
+ const renderInsertionCue = () => (
+
+ );
+
+ return (
+
+ lesson.lessonId)}
+ >
+ {lessons.map((lesson, index) => (
+
+ {safeInsertionCueIndex === index
+ ? renderInsertionCue()
+ : null}
+
+
+ ))}
+ {safeInsertionCueIndex === lessons.length
+ ? renderInsertionCue()
+ : null}
+
+
+ );
+}
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..142928c79 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,16 +1,8 @@
"use client";
import { useContext, useState } from "react";
-import { useRouter, useParams } from "next/navigation";
+import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
@@ -20,22 +12,9 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
-import {
- ChevronRight,
- MoreHorizontal,
- Plus,
- FileText,
- Video,
- HelpCircle,
- ChevronDown,
- Droplets,
-} from "lucide-react";
import Link from "next/link";
import {
- BUTTON_NEW_LESSON_TEXT,
- BUTTON_NEW_LESSON_TEXT_DOWNLOAD,
COURSE_CONTENT_HEADER,
- EDIT_SECTION_HEADER,
LESSON_GROUP_DELETED,
MANAGE_COURSES_PAGE_HEADING,
TOAST_TITLE_ERROR,
@@ -45,35 +24,31 @@ 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 { DragAndDrop, useToast } from "@courselit/components-library";
+import { Constants, Group, UIConstants } from "@courselit/common-models";
+import { useToast } from "@courselit/components-library";
import { FetchBuilder } from "@courselit/utils";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
+import { Plus } from "lucide-react";
+import ContentSectionsBoard from "./components/content-sections-board";
+
const { permissions } = UIConstants;
export default function ContentPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
- const [sectionMenuOpenId, setSectionMenuOpenId] = useState(
- null,
- );
const [itemToDelete, setItemToDelete] = useState | null>(null);
- const [collapsedSections, setCollapsedSections] = useState([]);
- const [hoveredSectionIndex, setHoveredSectionIndex] = useState<
- number | null
- >(null);
- const router = useRouter();
+ const [orderedSections, setOrderedSections] = useState(
+ null,
+ );
+
const params = useParams();
const productId = params.id as string;
const address = useContext(AddressContext);
const { product } = useProduct(productId);
+ const { toast } = useToast();
+ const resolvedOrderedSections = orderedSections ?? product?.groups ?? [];
+
const breadcrumbs = [
{ label: MANAGE_COURSES_PAGE_HEADING, href: "/dashboard/products" },
{
@@ -82,72 +57,6 @@ export default function ContentPage() {
},
{ label: COURSE_CONTENT_HEADER, href: "#" },
];
- const { toast } = useToast();
-
- const handleDelete = async () => {
- const item = itemToDelete;
- setDeleteDialogOpen(false);
- setItemToDelete(null);
- setSectionMenuOpenId(null);
- await removeGroup(item?.id!, product?.courseId!);
- };
-
- const toggleSectionCollapse = (sectionId: string) => {
- setCollapsedSections((prev) =>
- prev.includes(sectionId)
- ? prev.filter((id) => id !== sectionId)
- : [...prev, sectionId],
- );
- };
-
- const LessonTypeIcon = ({ type }) => {
- switch (type) {
- case "video":
- return ;
- case "text":
- return ;
- case "quiz":
- return ;
- default:
- return null;
- }
- };
-
- const updateGroup = async (group, lessonsOrder: string[]) => {
- const mutation = `
- mutation UpdateGroup ($id: ID!, $courseId: String!, $lessonsOrder: [String]!) {
- updateGroup(
- id: $id,
- courseId: $courseId,
- lessonsOrder: $lessonsOrder
- ) {
- courseId,
- title
- }
- }
- `;
- const fetch = new FetchBuilder()
- .setUrl(`${address.backend}/api/graph`)
- .setPayload({
- query: mutation,
- variables: {
- id: group.id,
- courseId: product?.courseId,
- lessonsOrder,
- },
- })
- .setIsGraphQLEndpoint(true)
- .build();
- try {
- await fetch.exec();
- } catch (err: any) {
- toast({
- title: TOAST_TITLE_ERROR,
- description: err.message,
- variant: "destructive",
- });
- }
- };
const removeGroup = async (groupId: string, courseId: string) => {
const mutation = `
@@ -166,22 +75,24 @@ export default function ContentPage() {
query: mutation,
variables: {
id: groupId,
- courseId: courseId,
+ courseId,
},
})
.setIsGraphQLEndpoint(true)
.build();
+
try {
const response = await fetch.exec();
if (response.removeGroup?.courseId) {
+ setOrderedSections((prev) =>
+ (prev ?? product?.groups ?? []).filter(
+ (section) => section.id !== groupId,
+ ),
+ );
toast({
title: TOAST_TITLE_SUCCESS,
description: LESSON_GROUP_DELETED,
});
- // course.groups.splice(
- // course.groups.findIndex((group) => group.id === groupId),
- // 1,
- // );
}
} catch (err: any) {
toast({
@@ -192,6 +103,16 @@ export default function ContentPage() {
}
};
+ const handleDelete = async () => {
+ if (!itemToDelete?.id || !product?.courseId) {
+ return;
+ }
+
+ setDeleteDialogOpen(false);
+ await removeGroup(itemToDelete.id, product.courseId);
+ setItemToDelete(null);
+ };
+
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,
- )
- }
- >
-
-
-
-
-
- router.push(
- `/dashboard/product/${productId}/content/section/${section.id}`,
- )
- }
- >
- {EDIT_SECTION_HEADER}
-
- {/*
- router.push(
- `/dashboard/product/${productId}/content/section/new?after=${section.id}`,
- )
- }
- >
- 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),
- )
- .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}`,
- )
- }
- >
-
-
-
- {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);
- }}
- />
-
-
- )}
- {hoveredSectionIndex === index && (
-
- )}
-
- ))}
+ {product?.courseId ? (
+ {
+ setItemToDelete({
+ type: "section",
+ title: item.title,
+ id: item.id,
+ });
+ setDeleteDialogOpen(true);
+ }}
+ />
+ ) : null}
+
{product?.type?.toLowerCase() !==
Constants.CourseType.DOWNLOAD && (
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/page.tsx
index 5b543031c..f7f1a4554 100644
--- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/page.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/section/[section]/page.tsx
@@ -585,7 +585,7 @@ export default function SectionPage(props: {
({
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+jest.mock("@courselit/components-library", () => ({
+ Avatar: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AvatarFallback: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ AvatarImage: ({ src }: { src?: string }) =>
,
+ Checkbox: ({
+ checked,
+ onChange,
+ }: {
+ checked: boolean;
+ onChange: (value: boolean) => void;
+ }) => (
+ onChange(event.target.checked)}
+ />
+ ),
+ Image: ({ alt, src }: { alt: string; src: string }) => (
+
+ ),
+ MediaSelector: () => null,
+ useToast: () => ({
+ toast: mockToast,
+ }),
+}));
+
+jest.mock("@components/ui/field", () => ({
+ Field: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ FieldContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ FieldGroup: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ FieldLabel: ({
+ children,
+ htmlFor,
+ }: {
+ children: React.ReactNode;
+ htmlFor?: string;
+ }) => ,
+ FieldLegend: ({ children }: { children: React.ReactNode }) => (
+
+ ),
+ FieldSet: ({ children }: { children: React.ReactNode }) => (
+
+ ),
+}));
+
+jest.mock("@components/ui/card", () => ({
+ Card: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardHeader: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+jest.mock("@components/ui/input", () => ({
+ Input: (props: React.InputHTMLAttributes) => (
+
+ ),
+}));
+
+jest.mock("@components/ui/textarea", () => ({
+ Textarea: (props: React.TextareaHTMLAttributes) => (
+
+ ),
+}));
+
+jest.mock("@components/ui/button", () => ({
+ Button: ({
+ children,
+ ...props
+ }: React.ButtonHTMLAttributes) => (
+
+ ),
+}));
+
+jest.mock("@courselit/utils", () => ({
+ FetchBuilder: jest.fn().mockImplementation(() => ({
+ setUrl: jest.fn().mockReturnThis(),
+ setPayload: jest.fn().mockReturnThis(),
+ setIsGraphQLEndpoint: jest.fn().mockReturnThis(),
+ build: jest.fn().mockReturnThis(),
+ exec: mockExec,
+ })),
+}));
+
+function renderPage() {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe("ProfilePage", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockExec
+ .mockResolvedValueOnce({
+ user: {
+ name: "Jane Doe",
+ bio: "Old bio",
+ email: "jane@example.com",
+ subscribedToUpdates: false,
+ avatar: {
+ thumbnail: "old-avatar.png",
+ },
+ },
+ })
+ .mockResolvedValueOnce({
+ user: {
+ id: "db-user-1",
+ name: "Jane Updated",
+ userId: "user-1",
+ email: "jane@example.com",
+ permissions: [],
+ purchases: [],
+ bio: "Old bio",
+ avatar: {
+ thumbnail: "old-avatar.png",
+ },
+ },
+ });
+ });
+
+ it("preserves fetched profile state after saving details", async () => {
+ renderPage();
+
+ const nameInput = await screen.findByDisplayValue("Jane Doe");
+ fireEvent.change(nameInput, {
+ target: {
+ value: "Jane Updated",
+ },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
+
+ await waitFor(() => {
+ expect(mockSetProfile).toHaveBeenCalledTimes(1);
+ });
+
+ const updater = mockSetProfile.mock.calls[0][0];
+ expect(typeof updater).toBe("function");
+
+ expect(
+ updater({
+ userId: "user-1",
+ name: "Jane Doe",
+ email: "jane@example.com",
+ bio: "Old bio",
+ permissions: [],
+ purchases: [],
+ fetched: true,
+ subscribedToUpdates: false,
+ avatar: {
+ thumbnail: "old-avatar.png",
+ },
+ }),
+ ).toMatchObject({
+ userId: "user-1",
+ name: "Jane Updated",
+ email: "jane@example.com",
+ bio: "Old bio",
+ fetched: true,
+ subscribedToUpdates: false,
+ });
+ });
+});
diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/profile/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/profile/page.tsx
index 2e033ab58..98cfafa86 100644
--- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/profile/page.tsx
+++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/profile/page.tsx
@@ -188,6 +188,7 @@ export default function Page() {
certificateId
},
bio,
+ subscribedToUpdates,
avatar {
mediaId,
originalFileName,
@@ -217,7 +218,11 @@ export default function Page() {
try {
const response = await fetch.exec();
if (response.user) {
- setProfile(response.user);
+ setProfile((currentProfile: Partial | null) => ({
+ ...(currentProfile || {}),
+ ...response.user,
+ fetched: true,
+ }));
initialDetailsRef.current = {
name,
bio,
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__/logic.test.ts b/apps/web/graphql/courses/__tests__/logic.test.ts
index 0f8d7fe70..9f3896e13 100644
--- a/apps/web/graphql/courses/__tests__/logic.test.ts
+++ b/apps/web/graphql/courses/__tests__/logic.test.ts
@@ -3,7 +3,7 @@ import UserModel from "@models/User";
import CourseModel from "@models/Course";
import PageModel from "@models/Page";
import constants from "@/config/constants";
-import { updateCourse } from "../logic";
+import { getCourse, updateCourse } from "../logic";
import { deleteMedia, sealMedia } from "@/services/medialit";
jest.mock("@/services/medialit", () => ({
@@ -20,6 +20,10 @@ const UPDATE_COURSE_SUITE_PREFIX = `update-course-${Date.now()}`;
const id = (suffix: string) => `${UPDATE_COURSE_SUITE_PREFIX}-${suffix}`;
const email = (suffix: string) =>
`${suffix}-${UPDATE_COURSE_SUITE_PREFIX}@example.com`;
+const GET_COURSE_SUITE_PREFIX = `get-course-${Date.now()}`;
+const getCourseId = (suffix: string) => `${GET_COURSE_SUITE_PREFIX}-${suffix}`;
+const getCourseEmail = (suffix: string) =>
+ `${suffix}-${GET_COURSE_SUITE_PREFIX}@example.com`;
describe("updateCourse", () => {
let testDomain: any;
@@ -226,3 +230,89 @@ describe("updateCourse", () => {
);
});
});
+
+describe("getCourse", () => {
+ let testDomain: any;
+ let adminUser: any;
+
+ beforeAll(async () => {
+ testDomain = await DomainModel.create({
+ name: getCourseId("domain"),
+ email: getCourseEmail("domain"),
+ });
+
+ adminUser = await UserModel.create({
+ domain: testDomain._id,
+ userId: getCourseId("admin-user"),
+ email: getCourseEmail("admin"),
+ name: "Admin User",
+ permissions: [constants.permissions.manageAnyCourse],
+ active: true,
+ unsubscribeToken: getCourseId("unsubscribe-admin"),
+ 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("returns groups sorted by rank", async () => {
+ const groupId1 = getCourseId("group-1");
+ const groupId2 = getCourseId("group-2");
+ const groupId3 = getCourseId("group-3");
+ const course = await CourseModel.create({
+ domain: testDomain._id,
+ courseId: getCourseId("course"),
+ title: getCourseId("course-title"),
+ creatorId: adminUser.userId,
+ groups: [
+ {
+ _id: groupId2,
+ name: "Group 2",
+ rank: 2000,
+ collapsed: true,
+ lessonsOrder: [],
+ },
+ {
+ _id: groupId1,
+ name: "Group 1",
+ rank: 1000,
+ collapsed: true,
+ lessonsOrder: [],
+ },
+ {
+ _id: groupId3,
+ name: "Group 3",
+ rank: 3000,
+ collapsed: true,
+ lessonsOrder: [],
+ },
+ ],
+ lessons: [],
+ type: "course",
+ privacy: "unlisted",
+ costType: "free",
+ cost: 0,
+ slug: getCourseId("course-slug"),
+ });
+
+ const formattedCourse = await getCourse(course.courseId, {
+ subdomain: testDomain,
+ user: adminUser,
+ address: "",
+ });
+
+ expect(formattedCourse?.groups?.map((group: any) => group.id)).toEqual([
+ groupId1,
+ groupId2,
+ groupId3,
+ ]);
+ });
+});
diff --git a/apps/web/graphql/courses/__tests__/move-lesson.test.ts b/apps/web/graphql/courses/__tests__/move-lesson.test.ts
new file mode 100644
index 000000000..832ad9056
--- /dev/null
+++ b/apps/web/graphql/courses/__tests__/move-lesson.test.ts
@@ -0,0 +1,523 @@
+import DomainModel from "@models/Domain";
+import UserModel from "@models/User";
+import CourseModel from "@models/Course";
+import LessonModel from "@models/Lesson";
+import constants from "@/config/constants";
+import { responses } from "@/config/strings";
+import { moveLesson } from "../logic";
+
+const SUITE_PREFIX = `move-lesson-${Date.now()}`;
+const id = (suffix: string) => `${SUITE_PREFIX}-${suffix}`;
+const email = (suffix: string) => `${suffix}-${SUITE_PREFIX}@example.com`;
+
+describe("moveLesson", () => {
+ let testDomain: any;
+ let adminUser: any;
+ let ownerWithManageCourse: 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: [],
+ });
+
+ 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 });
+ await LessonModel.deleteMany({ domain: testDomain._id });
+ });
+
+ afterAll(async () => {
+ await CourseModel.deleteMany({ domain: testDomain._id });
+ await LessonModel.deleteMany({ domain: testDomain._id });
+ await UserModel.deleteMany({ domain: testDomain._id });
+ await DomainModel.deleteOne({ _id: testDomain._id });
+ });
+
+ it("reorders lessons within the same group", async () => {
+ const groupId = id("group-1");
+ const lesson1 = id("lesson-1");
+ const lesson2 = id("lesson-2");
+ const lesson3 = id("lesson-3");
+ const course = await CourseModel.create({
+ domain: testDomain._id,
+ courseId: id("course-same-group"),
+ title: id("course-title-same-group"),
+ creatorId: ownerWithManageCourse.userId,
+ groups: [
+ {
+ _id: groupId,
+ name: "Group 1",
+ rank: 1000,
+ collapsed: true,
+ lessonsOrder: [lesson1, lesson2, lesson3],
+ },
+ ],
+ lessons: [lesson1, lesson2, lesson3],
+ type: "course",
+ privacy: "unlisted",
+ costType: "free",
+ cost: 0,
+ slug: id("course-slug-same-group"),
+ });
+
+ await LessonModel.insertMany([
+ {
+ domain: testDomain._id,
+ lessonId: lesson1,
+ title: "Lesson 1",
+ type: "text",
+ content: {},
+ creatorId: ownerWithManageCourse.userId,
+ courseId: course.courseId,
+ groupId,
+ },
+ {
+ domain: testDomain._id,
+ lessonId: lesson2,
+ title: "Lesson 2",
+ type: "text",
+ content: {},
+ creatorId: ownerWithManageCourse.userId,
+ courseId: course.courseId,
+ groupId,
+ },
+ {
+ domain: testDomain._id,
+ lessonId: lesson3,
+ title: "Lesson 3",
+ type: "text",
+ content: {},
+ creatorId: ownerWithManageCourse.userId,
+ courseId: course.courseId,
+ groupId,
+ },
+ ]);
+
+ await moveLesson({
+ courseId: course.courseId,
+ lessonId: lesson1,
+ destinationGroupId: groupId,
+ destinationIndex: 2,
+ ctx: {
+ subdomain: testDomain,
+ user: ownerWithManageCourse,
+ address: "",
+ },
+ });
+
+ const updatedCourse = await CourseModel.findOne({
+ domain: testDomain._id,
+ courseId: course.courseId,
+ }).lean();
+
+ expect(updatedCourse?.groups?.[0]?.lessonsOrder).toEqual([
+ lesson2,
+ lesson3,
+ lesson1,
+ ]);
+ });
+
+ it("moves lessons across groups and updates lesson.groupId", async () => {
+ const groupId1 = id("group-1");
+ const groupId2 = id("group-2");
+ const lesson1 = id("lesson-1");
+ const lesson2 = id("lesson-2");
+ const course = await CourseModel.create({
+ domain: testDomain._id,
+ courseId: id("course-cross-group"),
+ title: id("course-title-cross-group"),
+ creatorId: ownerWithManageCourse.userId,
+ groups: [
+ {
+ _id: groupId1,
+ name: "Group 1",
+ rank: 1000,
+ collapsed: true,
+ lessonsOrder: [lesson1, lesson2],
+ },
+ {
+ _id: groupId2,
+ name: "Group 2",
+ rank: 2000,
+ collapsed: true,
+ lessonsOrder: [],
+ },
+ ],
+ lessons: [lesson1, lesson2],
+ type: "course",
+ privacy: "unlisted",
+ costType: "free",
+ cost: 0,
+ slug: id("course-slug-cross-group"),
+ });
+
+ await LessonModel.insertMany([
+ {
+ domain: testDomain._id,
+ lessonId: lesson1,
+ title: "Lesson 1",
+ type: "text",
+ content: {},
+ creatorId: ownerWithManageCourse.userId,
+ courseId: course.courseId,
+ groupId: groupId1,
+ },
+ {
+ domain: testDomain._id,
+ lessonId: lesson2,
+ title: "Lesson 2",
+ type: "text",
+ content: {},
+ creatorId: ownerWithManageCourse.userId,
+ courseId: course.courseId,
+ groupId: groupId1,
+ },
+ ]);
+
+ await moveLesson({
+ courseId: course.courseId,
+ lessonId: lesson2,
+ destinationGroupId: groupId2,
+ destinationIndex: 0,
+ ctx: {
+ subdomain: testDomain,
+ user: ownerWithManageCourse,
+ address: "",
+ },
+ });
+
+ const updatedCourse = await CourseModel.findOne({
+ domain: testDomain._id,
+ courseId: course.courseId,
+ }).lean();
+ expect(updatedCourse?.groups?.[0]?.lessonsOrder).toEqual([lesson1]);
+ expect(updatedCourse?.groups?.[1]?.lessonsOrder).toEqual([lesson2]);
+
+ const updatedLesson = await LessonModel.findOne({
+ domain: testDomain._id,
+ lessonId: lesson2,
+ }).lean();
+ expect(updatedLesson?.groupId).toBe(groupId2);
+ });
+
+ it("rejects move when lesson id is not part of course.lessons", async () => {
+ const groupId = id("group-1");
+ const lessonId = id("lesson-not-listed");
+ const course = await CourseModel.create({
+ domain: testDomain._id,
+ courseId: id("course-invalid-membership"),
+ title: id("course-title-invalid-membership"),
+ creatorId: ownerWithManageCourse.userId,
+ groups: [
+ {
+ _id: groupId,
+ name: "Group 1",
+ rank: 1000,
+ collapsed: true,
+ lessonsOrder: [lessonId],
+ },
+ ],
+ lessons: [],
+ type: "course",
+ privacy: "unlisted",
+ costType: "free",
+ cost: 0,
+ slug: id("course-slug-invalid-membership"),
+ });
+
+ await LessonModel.create({
+ domain: testDomain._id,
+ lessonId,
+ title: "Lesson 1",
+ type: "text",
+ content: {},
+ creatorId: ownerWithManageCourse.userId,
+ courseId: course.courseId,
+ groupId,
+ });
+
+ await expect(
+ moveLesson({
+ courseId: course.courseId,
+ lessonId,
+ destinationGroupId: groupId,
+ destinationIndex: 0,
+ ctx: {
+ subdomain: testDomain,
+ user: ownerWithManageCourse,
+ address: "",
+ },
+ }),
+ ).rejects.toThrow(responses.invalid_input);
+ });
+
+ it("rejects unknown destination groups", async () => {
+ const groupId = id("group-1");
+ const unknownGroup = id("group-unknown");
+ const lessonId = id("lesson-1");
+ const course = await CourseModel.create({
+ domain: testDomain._id,
+ courseId: id("course-unknown-group"),
+ title: id("course-title-unknown-group"),
+ creatorId: ownerWithManageCourse.userId,
+ groups: [
+ {
+ _id: groupId,
+ name: "Group 1",
+ rank: 1000,
+ collapsed: true,
+ lessonsOrder: [lessonId],
+ },
+ ],
+ lessons: [lessonId],
+ type: "course",
+ privacy: "unlisted",
+ costType: "free",
+ cost: 0,
+ slug: id("course-slug-unknown-group"),
+ });
+
+ await LessonModel.create({
+ domain: testDomain._id,
+ lessonId,
+ title: "Lesson 1",
+ type: "text",
+ content: {},
+ creatorId: ownerWithManageCourse.userId,
+ courseId: course.courseId,
+ groupId,
+ });
+
+ await expect(
+ moveLesson({
+ courseId: course.courseId,
+ lessonId,
+ destinationGroupId: unknownGroup,
+ destinationIndex: 0,
+ ctx: {
+ subdomain: testDomain,
+ user: ownerWithManageCourse,
+ address: "",
+ },
+ }),
+ ).rejects.toThrow(responses.invalid_input);
+ });
+
+ it("rejects unknown lesson ids", async () => {
+ const groupId = id("group-1");
+ const course = await CourseModel.create({
+ domain: testDomain._id,
+ courseId: id("course-unknown-lesson"),
+ title: id("course-title-unknown-lesson"),
+ creatorId: ownerWithManageCourse.userId,
+ groups: [
+ {
+ _id: groupId,
+ name: "Group 1",
+ rank: 1000,
+ collapsed: true,
+ lessonsOrder: [],
+ },
+ ],
+ lessons: [],
+ type: "course",
+ privacy: "unlisted",
+ costType: "free",
+ cost: 0,
+ slug: id("course-slug-unknown-lesson"),
+ });
+
+ await expect(
+ moveLesson({
+ courseId: course.courseId,
+ lessonId: id("missing-lesson"),
+ destinationGroupId: groupId,
+ destinationIndex: 0,
+ ctx: {
+ subdomain: testDomain,
+ user: ownerWithManageCourse,
+ address: "",
+ },
+ }),
+ ).rejects.toThrow(responses.item_not_found);
+ });
+
+ it("rejects unknown courses", async () => {
+ const lessonId = id("lesson-for-missing-course");
+
+ await LessonModel.create({
+ domain: testDomain._id,
+ lessonId,
+ title: "Lesson 1",
+ type: "text",
+ content: {},
+ creatorId: ownerWithManageCourse.userId,
+ courseId: id("different-course"),
+ groupId: id("group-1"),
+ });
+
+ await expect(
+ moveLesson({
+ courseId: id("missing-course"),
+ lessonId,
+ destinationGroupId: id("group-1"),
+ destinationIndex: 0,
+ ctx: {
+ subdomain: testDomain,
+ user: ownerWithManageCourse,
+ address: "",
+ },
+ }),
+ ).rejects.toThrow(responses.item_not_found);
+ });
+
+ it("normalizes duplicate ordering entries by keeping one destination entry", async () => {
+ const groupId1 = id("group-1");
+ const groupId2 = id("group-2");
+ const lessonId = id("lesson-1");
+ const course = await CourseModel.create({
+ domain: testDomain._id,
+ courseId: id("course-duplicate-order"),
+ title: id("course-title-duplicate-order"),
+ creatorId: ownerWithManageCourse.userId,
+ groups: [
+ {
+ _id: groupId1,
+ name: "Group 1",
+ rank: 1000,
+ collapsed: true,
+ lessonsOrder: [lessonId],
+ },
+ {
+ _id: groupId2,
+ name: "Group 2",
+ rank: 2000,
+ collapsed: true,
+ lessonsOrder: [lessonId],
+ },
+ ],
+ lessons: [lessonId],
+ type: "course",
+ privacy: "unlisted",
+ costType: "free",
+ cost: 0,
+ slug: id("course-slug-duplicate-order"),
+ });
+
+ await LessonModel.create({
+ domain: testDomain._id,
+ lessonId,
+ title: "Lesson 1",
+ type: "text",
+ content: {},
+ creatorId: ownerWithManageCourse.userId,
+ courseId: course.courseId,
+ groupId: groupId1,
+ });
+
+ await moveLesson({
+ courseId: course.courseId,
+ lessonId,
+ destinationGroupId: groupId2,
+ destinationIndex: 0,
+ ctx: {
+ subdomain: testDomain,
+ user: adminUser,
+ address: "",
+ },
+ });
+
+ const updatedCourse = await CourseModel.findOne({
+ domain: testDomain._id,
+ courseId: course.courseId,
+ }).lean();
+ expect(updatedCourse?.groups?.[0]?.lessonsOrder).toEqual([]);
+ expect(updatedCourse?.groups?.[1]?.lessonsOrder).toEqual([lessonId]);
+ });
+
+ it("rejects non-owner users without manageAnyCourse permission", async () => {
+ const groupId = id("group-1");
+ const lessonId = id("lesson-1");
+ const course = await CourseModel.create({
+ domain: testDomain._id,
+ courseId: id("course-non-owner"),
+ title: id("course-title-non-owner"),
+ creatorId: ownerWithManageCourse.userId,
+ groups: [
+ {
+ _id: groupId,
+ name: "Group 1",
+ rank: 1000,
+ collapsed: true,
+ lessonsOrder: [lessonId],
+ },
+ ],
+ lessons: [lessonId],
+ type: "course",
+ privacy: "unlisted",
+ costType: "free",
+ cost: 0,
+ slug: id("course-slug-non-owner"),
+ });
+
+ await LessonModel.create({
+ domain: testDomain._id,
+ lessonId,
+ title: "Lesson 1",
+ type: "text",
+ content: {},
+ creatorId: ownerWithManageCourse.userId,
+ courseId: course.courseId,
+ groupId,
+ });
+
+ await expect(
+ moveLesson({
+ courseId: course.courseId,
+ lessonId,
+ destinationGroupId: groupId,
+ destinationIndex: 0,
+ ctx: {
+ subdomain: testDomain,
+ user: otherUserWithManageCourse,
+ address: "",
+ },
+ }),
+ ).rejects.toThrow(responses.item_not_found);
+ });
+});
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..1192dac88
--- /dev/null
+++ b/apps/web/graphql/courses/__tests__/reorder-groups.test.ts
@@ -0,0 +1,447 @@
+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"),
+ });
+
+ const reorderedCourse = 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);
+ expect(
+ (updatedCourse?.groups ?? []).map((group: any) =>
+ group._id.toString(),
+ ),
+ ).toEqual([groupId3, groupId1, groupId2]);
+ expect(
+ (reorderedCourse.groups ?? []).map((group: any) => group.id),
+ ).toEqual([groupId3, groupId1, groupId2]);
+ });
+
+ 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/__tests__/update-group-metadata.test.ts b/apps/web/graphql/courses/__tests__/update-group-metadata.test.ts
new file mode 100644
index 000000000..17ffa674b
--- /dev/null
+++ b/apps/web/graphql/courses/__tests__/update-group-metadata.test.ts
@@ -0,0 +1,91 @@
+import DomainModel from "@models/Domain";
+import UserModel from "@models/User";
+import CourseModel from "@models/Course";
+import constants from "@/config/constants";
+import { updateGroup } from "../logic";
+
+const SUITE_PREFIX = `update-group-metadata-${Date.now()}`;
+const id = (suffix: string) => `${SUITE_PREFIX}-${suffix}`;
+const email = (suffix: string) => `${suffix}-${SUITE_PREFIX}@example.com`;
+
+describe("updateGroup metadata updates", () => {
+ let testDomain: any;
+ let adminUser: 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: [],
+ });
+ });
+
+ 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("updates group name, rank and collapsed without touching lessonsOrder", async () => {
+ const groupId = id("group");
+ const course = await CourseModel.create({
+ domain: testDomain._id,
+ courseId: id("course"),
+ title: id("course-title"),
+ creatorId: adminUser.userId,
+ groups: [
+ {
+ _id: groupId,
+ name: "Group 1",
+ rank: 1000,
+ collapsed: true,
+ lessonsOrder: [id("lesson-1")],
+ },
+ ],
+ lessons: [id("lesson-1")],
+ type: "course",
+ privacy: "unlisted",
+ costType: "free",
+ cost: 0,
+ slug: id("course-slug"),
+ });
+
+ await updateGroup({
+ id: groupId,
+ courseId: course.courseId,
+ name: "Renamed Group",
+ rank: 2000,
+ collapsed: false,
+ ctx: {
+ subdomain: testDomain,
+ user: adminUser,
+ address: "",
+ },
+ });
+
+ const updatedCourse = await CourseModel.findOne({
+ domain: testDomain._id,
+ courseId: course.courseId,
+ }).lean();
+ const updatedGroup = updatedCourse?.groups?.[0];
+
+ expect(updatedGroup?.name).toBe("Renamed Group");
+ expect(updatedGroup?.rank).toBe(2000);
+ expect(updatedGroup?.collapsed).toBe(false);
+ expect(updatedGroup?.lessonsOrder).toEqual([id("lesson-1")]);
+ });
+});
diff --git a/apps/web/graphql/courses/logic.ts b/apps/web/graphql/courses/logic.ts
index 0956112c2..aa4075da8 100644
--- a/apps/web/graphql/courses/logic.ts
+++ b/apps/web/graphql/courses/logic.ts
@@ -18,7 +18,7 @@ import {
setupCourse,
validateCourse,
} from "./helpers";
-import Lesson from "@/models/Lesson";
+import LessonModel from "@/models/Lesson";
import GQLContext from "@/models/GQLContext";
import Filter from "./models/filter";
import mongoose from "mongoose";
@@ -129,12 +129,20 @@ async function formatCourse(
(course as any).firstLesson = nextLesson;
}
- const result = {
- ...course,
- groups: course!.groups?.map((group: any) => ({
+ const sortedGroups = course!.groups
+ ?.map((group: any) => ({
...group,
id: group._id.toString(),
- })),
+ }))
+ .sort(
+ (groupA: any, groupB: any) =>
+ (groupA.rank ?? Number.MAX_SAFE_INTEGER) -
+ (groupB.rank ?? Number.MAX_SAFE_INTEGER),
+ );
+
+ const result = {
+ ...course,
+ groups: sortedGroups,
paymentPlans,
};
return result;
@@ -756,7 +764,7 @@ export const removeGroup = async (
throw new Error(responses.download_course_last_group_cannot_be_removed);
}
- const countOfAssociatedLessons = await Lesson.countDocuments({
+ const countOfAssociatedLessons = await LessonModel.countDocuments({
courseId,
groupId: group.id,
domain: ctx.subdomain._id,
@@ -792,7 +800,6 @@ export const updateGroup = async ({
name,
rank,
collapsed,
- lessonsOrder,
drip,
ctx,
}: {
@@ -801,7 +808,6 @@ export const updateGroup = async ({
name?: string;
rank?: number;
collapsed?: boolean;
- lessonsOrder?: string[];
drip?: {
type?: string;
status?: boolean;
@@ -835,18 +841,6 @@ export const updateGroup = async ({
$set["groups.$.rank"] = rank;
}
- if (
- lessonsOrder &&
- lessonsOrder.every((lessonId) => course.lessons?.includes(lessonId)) &&
- lessonsOrder.every((lessonId) =>
- course.groups
- ?.find((group) => group.id === id)
- ?.lessonsOrder.includes(lessonId),
- )
- ) {
- $set["groups.$.lessonsOrder"] = lessonsOrder;
- }
-
if (typeof collapsed === "boolean") {
$set["groups.$.collapsed"] = collapsed;
}
@@ -906,6 +900,148 @@ export const updateGroup = async ({
);
};
+export const moveLesson = async ({
+ courseId,
+ lessonId,
+ destinationGroupId,
+ destinationIndex,
+ ctx,
+}: {
+ courseId: string;
+ lessonId: string;
+ destinationGroupId: string;
+ destinationIndex: number;
+ ctx: GQLContext;
+}) => {
+ const course = await getCourseOrThrow(undefined, ctx, courseId);
+ const lesson = await LessonModel.findOne({
+ domain: ctx.subdomain._id,
+ lessonId,
+ });
+
+ if (!lesson || lesson.courseId !== course.courseId) {
+ throw new Error(responses.item_not_found);
+ }
+
+ if (!course.lessons?.includes(lessonId)) {
+ throw new Error(responses.invalid_input);
+ }
+
+ const destinationGroup = course.groups?.find(
+ (group) => group.id === destinationGroupId,
+ );
+ if (!destinationGroup) {
+ throw new Error(responses.invalid_input);
+ }
+
+ const normalizedGroups = (course.groups ?? []).map((group) => {
+ const plainGroup =
+ typeof (group as any).toObject === "function"
+ ? (group as any).toObject()
+ : { ...group };
+
+ return {
+ ...plainGroup,
+ lessonsOrder: (plainGroup.lessonsOrder ?? []).filter(
+ (id: string) => id !== lessonId,
+ ),
+ };
+ });
+
+ const destinationGroupIndex = normalizedGroups.findIndex((group: any) => {
+ const groupId = group._id ?? group.id;
+ return groupId?.toString() === destinationGroupId;
+ });
+ if (destinationGroupIndex === -1) {
+ throw new Error(responses.invalid_input);
+ }
+
+ const destinationLessons =
+ normalizedGroups[destinationGroupIndex].lessonsOrder ?? [];
+ normalizedGroups[destinationGroupIndex].lessonsOrder = destinationLessons;
+ const safeDestinationIndex = Math.min(
+ Math.max(destinationIndex, 0),
+ destinationLessons.length,
+ );
+ destinationLessons.splice(safeDestinationIndex, 0, lessonId);
+
+ await CourseModel.updateOne(
+ {
+ domain: ctx.subdomain._id,
+ courseId: course.courseId,
+ },
+ {
+ $set: {
+ groups: normalizedGroups,
+ },
+ },
+ );
+
+ if (lesson.groupId !== destinationGroupId) {
+ lesson.groupId = destinationGroupId;
+ await lesson.save();
+ }
+
+ return await formatCourse(course.courseId, ctx);
+};
+
+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 plainGroupsById = new Map();
+ (course.groups ?? []).forEach((group) => {
+ const plainGroup =
+ typeof (group as any).toObject === "function"
+ ? (group as any).toObject()
+ : { ...group };
+
+ plainGroupsById.set(group.id, plainGroup);
+ });
+
+ const updatedGroups = groupIds.map((groupId, index) => ({
+ ...plainGroupsById.get(groupId),
+ rank: (index + 1) * GROUP_RANK_GAP,
+ }));
+
+ 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..b0ef65730 100644
--- a/apps/web/graphql/courses/mutation.ts
+++ b/apps/web/graphql/courses/mutation.ts
@@ -14,6 +14,8 @@ import {
removeGroup,
addGroup,
updateGroup,
+ moveLesson,
+ reorderGroups,
updateCourseCertificateTemplate,
} from "./logic";
import Filter from "./models/filter";
@@ -102,16 +104,13 @@ export default {
collapsed: {
type: GraphQLBoolean,
},
- lessonsOrder: {
- type: new GraphQLList(GraphQLString),
- },
drip: {
type: types.dripInputType,
},
},
resolve: async (
_: unknown,
- { id, courseId, name, rank, collapsed, lessonsOrder, drip },
+ { id, courseId, name, rank, collapsed, drip },
context,
) =>
updateGroup({
@@ -120,11 +119,54 @@ export default {
name,
rank,
collapsed,
- lessonsOrder,
drip,
ctx: context,
}),
},
+ moveLesson: {
+ type: types.courseType,
+ args: {
+ courseId: {
+ type: new GraphQLNonNull(GraphQLString),
+ },
+ lessonId: {
+ type: new GraphQLNonNull(GraphQLString),
+ },
+ destinationGroupId: {
+ type: new GraphQLNonNull(GraphQLString),
+ },
+ destinationIndex: {
+ type: new GraphQLNonNull(GraphQLInt),
+ },
+ },
+ resolve: async (
+ _: unknown,
+ { courseId, lessonId, destinationGroupId, destinationIndex },
+ context,
+ ) =>
+ moveLesson({
+ courseId,
+ lessonId,
+ destinationGroupId,
+ destinationIndex,
+ 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/apps/web/package.json b/apps/web/package.json
index 1b6017a29..aad160365 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,134 +1,137 @@
{
- "name": "@courselit/web",
- "version": "0.73.7",
- "private": true,
- "scripts": {
- "dev": "next dev",
- "build": "next build",
- "start": "next start",
- "prettier": "prettier --write **/*.ts"
- },
- "browserslist": {
- "production": [
- "chrome >= 109",
- "edge >= 109",
- "firefox >= 109",
- "safari >= 15.4",
- "ios_saf >= 15.4",
- "not dead"
- ],
- "development": [
- "last 1 chrome version",
- "last 1 firefox version",
- "last 1 safari version"
- ]
- },
- "dependencies": {
- "@better-auth/sso": "^1.4.6",
- "@courselit/common-logic": "workspace:^",
- "@courselit/common-models": "workspace:^",
- "@courselit/components-library": "workspace:^",
- "@courselit/email-editor": "workspace:^",
- "@courselit/icons": "workspace:^",
- "@courselit/orm-models": "workspace:^",
- "@courselit/page-blocks": "workspace:^",
- "@courselit/page-models": "workspace:^",
- "@courselit/page-primitives": "workspace:^",
- "@courselit/text-editor": "workspace:^",
- "@courselit/utils": "workspace:^",
- "@hookform/resolvers": "^3.9.1",
- "@radix-ui/react-alert-dialog": "^1.1.11",
- "@radix-ui/react-avatar": "^1.1.3",
- "@radix-ui/react-checkbox": "^1.1.4",
- "@radix-ui/react-collapsible": "^1.1.3",
- "@radix-ui/react-compose-refs": "^1.1.1",
- "@radix-ui/react-dialog": "^1.1.6",
- "@radix-ui/react-dropdown-menu": "^2.1.6",
- "@radix-ui/react-label": "^2.1.4",
- "@radix-ui/react-popover": "^1.1.6",
- "@radix-ui/react-progress": "^1.1.7",
- "@radix-ui/react-radio-group": "^1.2.3",
- "@radix-ui/react-scroll-area": "^1.2.3",
- "@radix-ui/react-select": "^2.1.6",
- "@radix-ui/react-separator": "^1.1.4",
- "@radix-ui/react-slot": "^1.2.3",
- "@radix-ui/react-switch": "^1.1.3",
- "@radix-ui/react-tabs": "^1.1.3",
- "@radix-ui/react-toast": "^1.2.6",
- "@radix-ui/react-toggle": "^1.1.6",
- "@radix-ui/react-toggle-group": "^1.1.7",
- "@radix-ui/react-tooltip": "^1.1.8",
- "@radix-ui/react-visually-hidden": "^1.1.0",
- "@stripe/stripe-js": "^5.4.0",
- "@types/base-64": "^1.0.0",
- "adm-zip": "^0.5.16",
- "archiver": "^5.3.1",
- "aws4": "^1.13.2",
- "base-64": "^1.0.0",
- "better-auth": "^1.4.1",
- "chart.js": "^4.4.7",
- "class-variance-authority": "^0.7.0",
- "clsx": "^2.1.1",
- "color-convert": "^3.1.0",
- "cookie": "^0.4.2",
- "date-fns": "^4.1.0",
- "graphql": "^16.10.0",
- "graphql-type-json": "^0.3.2",
- "jsdom": "^26.1.0",
- "lodash.debounce": "^4.0.8",
- "lucide-react": "^0.553.0",
- "medialit": "0.2.0",
- "mongodb": "^6.15.0",
- "mongoose": "^8.13.1",
- "next": "^16.0.10",
- "next-themes": "^0.4.6",
- "nodemailer": "^6.7.2",
- "pug": "^3.0.2",
- "razorpay": "^2.9.4",
- "react": "19.2.0",
- "react-chartjs-2": "^5.3.0",
- "react-csv": "^2.2.2",
- "react-dom": "19.2.0",
- "react-hook-form": "^7.54.1",
- "recharts": "^2.15.1",
- "remirror": "^3.0.1",
- "sharp": "^0.33.2",
- "slugify": "^1.6.5",
- "sonner": "^2.0.7",
- "stripe": "^17.5.0",
- "tailwind-merge": "^2.5.4",
- "tailwindcss-animate": "^1.0.7",
- "xml2js": "^0.6.2",
- "zod": "^3.24.1"
- },
- "devDependencies": {
- "@eslint/eslintrc": "^3.3.1",
- "@shelf/jest-mongodb": "^5.2.2",
- "@types/adm-zip": "^0.5.7",
- "@types/bcryptjs": "^2.4.2",
- "@types/cookie": "^0.4.1",
- "@types/mongodb": "^4.0.7",
- "@types/node": "17.0.21",
- "@types/nodemailer": "^6.4.4",
- "@types/pug": "^2.0.6",
- "@types/react": "19.2.4",
- "@types/xml2js": "^0.4.14",
- "eslint": "^9.12.0",
- "eslint-config-next": "16.0.3",
- "eslint-config-prettier": "^9.0.0",
- "identity-obj-proxy": "^3.0.0",
- "mongodb-memory-server": "^10.1.4",
- "postcss": "^8.4.27",
- "prettier": "^3.0.2",
- "tailwind-config": "workspace:^",
- "tailwindcss": "^3.4.1",
- "ts-jest": "^29.4.4",
- "tsconfig": "workspace:^",
- "typescript": "^5.6.2"
- },
- "pnpm": {
- "overrides": {
- "@types/react": "19.2.4"
+ "name": "@courselit/web",
+ "version": "0.73.7",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "prettier": "prettier --write **/*.ts"
+ },
+ "browserslist": {
+ "production": [
+ "chrome >= 109",
+ "edge >= 109",
+ "firefox >= 109",
+ "safari >= 15.4",
+ "ios_saf >= 15.4",
+ "not dead"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "dependencies": {
+ "@better-auth/sso": "^1.4.6",
+ "@courselit/common-logic": "workspace:^",
+ "@courselit/common-models": "workspace:^",
+ "@courselit/components-library": "workspace:^",
+ "@courselit/email-editor": "workspace:^",
+ "@courselit/icons": "workspace:^",
+ "@courselit/orm-models": "workspace:^",
+ "@courselit/page-blocks": "workspace:^",
+ "@courselit/page-models": "workspace:^",
+ "@courselit/page-primitives": "workspace:^",
+ "@courselit/text-editor": "workspace:^",
+ "@courselit/utils": "workspace:^",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^8.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@hookform/resolvers": "^3.9.1",
+ "@radix-ui/react-alert-dialog": "^1.1.11",
+ "@radix-ui/react-avatar": "^1.1.3",
+ "@radix-ui/react-checkbox": "^1.1.4",
+ "@radix-ui/react-collapsible": "^1.1.3",
+ "@radix-ui/react-compose-refs": "^1.1.1",
+ "@radix-ui/react-dialog": "^1.1.6",
+ "@radix-ui/react-dropdown-menu": "^2.1.6",
+ "@radix-ui/react-label": "^2.1.4",
+ "@radix-ui/react-popover": "^1.1.6",
+ "@radix-ui/react-progress": "^1.1.7",
+ "@radix-ui/react-radio-group": "^1.2.3",
+ "@radix-ui/react-scroll-area": "^1.2.3",
+ "@radix-ui/react-select": "^2.1.6",
+ "@radix-ui/react-separator": "^1.1.4",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-switch": "^1.1.3",
+ "@radix-ui/react-tabs": "^1.1.3",
+ "@radix-ui/react-toast": "^1.2.6",
+ "@radix-ui/react-toggle": "^1.1.6",
+ "@radix-ui/react-toggle-group": "^1.1.7",
+ "@radix-ui/react-tooltip": "^1.1.8",
+ "@radix-ui/react-visually-hidden": "^1.1.0",
+ "@stripe/stripe-js": "^5.4.0",
+ "@types/base-64": "^1.0.0",
+ "adm-zip": "^0.5.16",
+ "archiver": "^5.3.1",
+ "aws4": "^1.13.2",
+ "base-64": "^1.0.0",
+ "better-auth": "^1.4.1",
+ "chart.js": "^4.4.7",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.1",
+ "color-convert": "^3.1.0",
+ "cookie": "^0.4.2",
+ "date-fns": "^4.1.0",
+ "graphql": "^16.10.0",
+ "graphql-type-json": "^0.3.2",
+ "jsdom": "^26.1.0",
+ "lodash.debounce": "^4.0.8",
+ "lucide-react": "^0.553.0",
+ "medialit": "0.2.0",
+ "mongodb": "^6.15.0",
+ "mongoose": "^8.13.1",
+ "next": "^16.0.10",
+ "next-themes": "^0.4.6",
+ "nodemailer": "^6.7.2",
+ "pug": "^3.0.2",
+ "razorpay": "^2.9.4",
+ "react": "19.2.0",
+ "react-chartjs-2": "^5.3.0",
+ "react-csv": "^2.2.2",
+ "react-dom": "19.2.0",
+ "react-hook-form": "^7.54.1",
+ "recharts": "^2.15.1",
+ "remirror": "^3.0.1",
+ "sharp": "^0.33.2",
+ "slugify": "^1.6.5",
+ "sonner": "^2.0.7",
+ "stripe": "^17.5.0",
+ "tailwind-merge": "^2.5.4",
+ "tailwindcss-animate": "^1.0.7",
+ "xml2js": "^0.6.2",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@eslint/eslintrc": "^3.3.1",
+ "@shelf/jest-mongodb": "^5.2.2",
+ "@types/adm-zip": "^0.5.7",
+ "@types/bcryptjs": "^2.4.2",
+ "@types/cookie": "^0.4.1",
+ "@types/mongodb": "^4.0.7",
+ "@types/node": "17.0.21",
+ "@types/nodemailer": "^6.4.4",
+ "@types/pug": "^2.0.6",
+ "@types/react": "19.2.4",
+ "@types/xml2js": "^0.4.14",
+ "eslint": "^9.12.0",
+ "eslint-config-next": "16.0.3",
+ "eslint-config-prettier": "^9.0.0",
+ "identity-obj-proxy": "^3.0.0",
+ "mongodb-memory-server": "^10.1.4",
+ "postcss": "^8.4.27",
+ "prettier": "^3.0.2",
+ "tailwind-config": "workspace:^",
+ "tailwindcss": "^3.4.1",
+ "ts-jest": "^29.4.4",
+ "tsconfig": "workspace:^",
+ "typescript": "^5.6.2"
+ },
+ "pnpm": {
+ "overrides": {
+ "@types/react": "19.2.4"
+ }
}
- }
}
diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts
index 0ae207abc..2b339aa9f 100644
--- a/apps/web/ui-config/strings.ts
+++ b/apps/web/ui-config/strings.ts
@@ -461,6 +461,8 @@ export const EDIT_SECTION_DRIP = "Drip";
export const DRIP_SECTION_STATUS = "Enable Drip";
export const EDIT_SECTION_HEADER = "Edit Section";
export const DELETE_SECTION_HEADER = "Delete section";
+export const BUTTON_MOVE_SECTION_UP = "Move section up";
+export const BUTTON_MOVE_SECTION_DOWN = "Move section down";
export const PRICING_HEADER = "Pricing";
export const PRICING_DROPDOWN = "Pricing model";
export const PRICING_FREE = Constants.ProductPriceType.FREE;
diff --git a/apps/web/ui-lib/utils.ts b/apps/web/ui-lib/utils.ts
index dd521475a..7665f5b05 100644
--- a/apps/web/ui-lib/utils.ts
+++ b/apps/web/ui-lib/utils.ts
@@ -2,9 +2,7 @@ import { cache } from "react";
import type {
CommunityMemberStatus,
CommunityReportStatus,
- Course,
Features,
- Group,
Membership,
MembershipRole,
Page,
@@ -311,14 +309,6 @@ export const moveMemberUp = (arr: any[], index: number) =>
export const moveMemberDown = (arr: any[], index: number) =>
swapMembers(arr, index, index + 1);
-export const sortCourseGroups = (course: Course) => {
- if (!Array.isArray(course.groups)) {
- return [];
- }
-
- return [...course.groups].sort((a: Group, b: Group) => a.rank - b.rank);
-};
-
export function truncate(str?: string, length?: number) {
if (!str || !length) {
return "";
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}
/>
))}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fb0092095..6b19d7d5b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -258,6 +258,15 @@ importers:
'@courselit/utils':
specifier: workspace:^
version: link:../../packages/utils
+ '@dnd-kit/core':
+ specifier: ^6.3.1
+ version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@dnd-kit/sortable':
+ specifier: ^8.0.0
+ version: 8.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
+ '@dnd-kit/utilities':
+ specifier: ^3.2.2
+ version: 3.2.2(react@19.2.0)
'@hookform/resolvers':
specifier: ^3.9.1
version: 3.10.0(react-hook-form@7.56.1(react@19.2.0))
@@ -12743,6 +12752,11 @@ snapshots:
react: 18.3.1
tslib: 2.8.1
+ '@dnd-kit/accessibility@3.1.1(react@19.2.0)':
+ dependencies:
+ react: 19.2.0
+ tslib: 2.8.1
+
'@dnd-kit/core@6.3.1(react-dom@19.2.0(react@18.3.1))(react@18.3.1)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@18.3.1)
@@ -12751,6 +12765,14 @@ snapshots:
react-dom: 19.2.0(react@18.3.1)
tslib: 2.8.1
+ '@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@dnd-kit/accessibility': 3.1.1(react@19.2.0)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ tslib: 2.8.1
+
'@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@18.3.1))(react@18.3.1))(react@18.3.1)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@18.3.1))(react@18.3.1)
@@ -12758,11 +12780,23 @@ snapshots:
react: 18.3.1
tslib: 2.8.1
+ '@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.0)
+ react: 19.2.0
+ tslib: 2.8.1
+
'@dnd-kit/utilities@3.2.2(react@18.3.1)':
dependencies:
react: 18.3.1
tslib: 2.8.1
+ '@dnd-kit/utilities@3.2.2(react@19.2.0)':
+ dependencies:
+ react: 19.2.0
+ tslib: 2.8.1
+
'@docsearch/css@3.9.0': {}
'@docsearch/react@3.9.0(@algolia/client-search@4.24.0)(@types/react@18.3.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)':
@@ -19851,7 +19885,7 @@ snapshots:
'@next/eslint-plugin-next': 16.0.3
eslint: 9.39.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7))
@@ -19882,7 +19916,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.1
@@ -19897,14 +19931,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7))
transitivePeerDependencies:
- supports-color
@@ -19925,7 +19959,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3