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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand Up @@ -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

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion apps/docs/src/pages/en/courses/add-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 15 additions & 1 deletion apps/docs/src/pages/en/courses/section.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ Here’s how sections look in various parts of the platform.

![Edit Section Settings](/assets/products/edit-section-settings.png)

## Rearranging Sections

You can move sections up or down as you like. Click the chevron up or down buttons to move a section.

![Move a section](/assets/products/section-reordering.png)

## 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.

![Move a lesson](/assets/products/lesson-reordering.png)

## 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**.
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down
12 changes: 6 additions & 6 deletions apps/web/app/(with-contexts)/course-old/[slug]/[id]/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { sortCourseGroups } from "@ui-lib/utils";
import { Course, Group, Lesson } from "@courselit/common-models";
import { FetchBuilder } from "@courselit/utils";

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
},
Expand Down Expand Up @@ -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(),
},
},
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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",
]);
});
});
12 changes: 6 additions & 6 deletions apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { sortCourseGroups } from "@ui-lib/utils";
import { Course, Group, Lesson } from "@courselit/common-models";
import { FetchBuilder } from "@courselit/utils";

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading