Skip to content

Commit 127975e

Browse files
author
Rajat
committed
Curriculum block respects section ranks
1 parent c327f02 commit 127975e

9 files changed

Lines changed: 157 additions & 64 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- Use **Cursors** (`.cursor()`) to stream data from MongoDB, ensuring the script remains memory-efficient regardless of dataset size.
1717
- Use **Batching** with `bulkWrite` (e.g., batches of 500) to maximize performance and minimize network roundtrips.
1818
- Ensure **Idempotency** (safe to re-run) by using upserts or `$setOnInsert` where applicable.
19+
- 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`).
1920

2021
### Workspace map (core modules):
2122

@@ -48,6 +49,7 @@
4849
- Always add or update test when introducing changes to `apps/web/graphql` folder, even if nobody asked.
4950
- Run `pnpm test` to run the tests.
5051
- Fix any test or type errors until the whole suite is green.
52+
- 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.
5153

5254
## PR instructions
5355

apps/web/app/(with-contexts)/course-old/[slug]/[id]/helpers.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { sortCourseGroups } from "@ui-lib/utils";
21
import { Course, Group, Lesson } from "@courselit/common-models";
32
import { FetchBuilder } from "@courselit/utils";
43

@@ -92,17 +91,16 @@ export const getProduct = async (
9291
export function formatCourse(
9392
post: Course & { lessons: Lesson[]; firstLesson: string; groups: Group[] },
9493
): CourseFrontend {
95-
const sortedGroups = sortCourseGroups(post as Course);
96-
97-
for (const group of sortedGroups) {
98-
(group as GroupWithLessons).lessons = post.lessons
94+
const groupsWithLessons = post.groups.map((group) => ({
95+
...group,
96+
lessons: post.lessons
9997
.filter((lesson: Lesson) => lesson.groupId === group.id)
10098
.sort(
10199
(a: any, b: any) =>
102100
group.lessonsOrder?.indexOf(a.lessonId) -
103101
group.lessonsOrder?.indexOf(b.lessonId),
104-
);
105-
}
102+
),
103+
}));
106104

107105
return {
108106
title: post.title,
@@ -113,7 +111,7 @@ export function formatCourse(
113111
slug: post.slug,
114112
cost: post.cost,
115113
courseId: post.courseId,
116-
groups: sortedGroups as GroupWithLessons[],
114+
groups: groupsWithLessons as GroupWithLessons[],
117115
tags: post.tags,
118116
firstLesson: post.firstLesson,
119117
paymentPlans: post.paymentPlans,

apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { formatCourse } from "../helpers";
22

33
describe("course helpers formatCourse", () => {
4-
it("returns groups sorted by rank and lessons sorted by lessonsOrder", () => {
5-
const formatted = formatCourse({
4+
const makeCourse = () =>
5+
({
66
title: "Course",
77
description: "{}",
88
featuredImage: undefined,
@@ -46,14 +46,22 @@ describe("course helpers formatCourse", () => {
4646
groupId: "group-2",
4747
},
4848
],
49-
} as any);
49+
}) as any;
50+
51+
it("preserves group order from the backend response", () => {
52+
const formatted = formatCourse(makeCourse());
5053

5154
expect(formatted.groups.map((group) => group.id)).toEqual([
52-
"group-1",
5355
"group-2",
56+
"group-1",
5457
]);
58+
});
59+
60+
it("sorts lessons within each group by lessonsOrder", () => {
61+
const formatted = formatCourse(makeCourse());
62+
5563
expect(
56-
formatted.groups[1].lessons.map((lesson) => lesson.lessonId),
64+
formatted.groups[0].lessons.map((lesson) => lesson.lessonId),
5765
).toEqual(["lesson-3", "lesson-2"]);
5866
});
5967
});

apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { sortCourseGroups } from "@ui-lib/utils";
21
import { Course, Group, Lesson } from "@courselit/common-models";
32
import { FetchBuilder } from "@courselit/utils";
43

@@ -92,17 +91,16 @@ export const getProduct = async (
9291
export function formatCourse(
9392
post: Course & { lessons: Lesson[]; firstLesson: string; groups: Group[] },
9493
): CourseFrontend {
95-
const sortedGroups = sortCourseGroups(post as Course);
96-
97-
for (const group of sortedGroups) {
98-
(group as GroupWithLessons).lessons = post.lessons
94+
const groupsWithLessons = post.groups.map((group) => ({
95+
...group,
96+
lessons: post.lessons
9997
.filter((lesson: Lesson) => lesson.groupId === group.id)
10098
.sort(
10199
(a: any, b: any) =>
102100
group.lessonsOrder?.indexOf(a.lessonId) -
103101
group.lessonsOrder?.indexOf(b.lessonId),
104-
);
105-
}
102+
),
103+
}));
106104

107105
return {
108106
title: post.title,
@@ -113,7 +111,7 @@ export function formatCourse(
113111
slug: post.slug,
114112
cost: post.cost,
115113
courseId: post.courseId,
116-
groups: sortedGroups as GroupWithLessons[],
114+
groups: groupsWithLessons as GroupWithLessons[],
117115
tags: post.tags,
118116
firstLesson: post.firstLesson,
119117
paymentPlans: post.paymentPlans,

apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/page.tsx

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useContext, useEffect, useMemo, useState } from "react";
3+
import { useContext, useState } from "react";
44
import { useParams } from "next/navigation";
55
import { Button } from "@/components/ui/button";
66
import {
@@ -24,7 +24,7 @@ import DashboardContent from "@components/admin/dashboard-content";
2424
import { AddressContext } from "@components/contexts";
2525
import useProduct from "@/hooks/use-product";
2626
import { truncate } from "@ui-lib/utils";
27-
import { Constants, UIConstants } from "@courselit/common-models";
27+
import { Constants, Group, UIConstants } from "@courselit/common-models";
2828
import { useToast } from "@courselit/components-library";
2929
import { FetchBuilder } from "@courselit/utils";
3030
import { Plus } from "lucide-react";
@@ -38,25 +38,16 @@ export default function ContentPage() {
3838
string,
3939
string
4040
> | null>(null);
41-
const [orderedSections, setOrderedSections] = useState<any[]>([]);
41+
const [orderedSections, setOrderedSections] = useState<Group[] | null>(
42+
null,
43+
);
4244

4345
const params = useParams();
4446
const productId = params.id as string;
4547
const address = useContext(AddressContext);
4648
const { product } = useProduct(productId);
4749
const { toast } = useToast();
48-
49-
const sortedProductGroups = useMemo(
50-
() =>
51-
[...(product?.groups ?? [])].sort(
52-
(a, b) => (a.rank ?? 0) - (b.rank ?? 0),
53-
),
54-
[product?.groups],
55-
);
56-
57-
useEffect(() => {
58-
setOrderedSections(sortedProductGroups);
59-
}, [sortedProductGroups]);
50+
const resolvedOrderedSections = orderedSections ?? product?.groups ?? [];
6051

6152
const breadcrumbs = [
6253
{ label: MANAGE_COURSES_PAGE_HEADING, href: "/dashboard/products" },
@@ -94,7 +85,9 @@ export default function ContentPage() {
9485
const response = await fetch.exec();
9586
if (response.removeGroup?.courseId) {
9687
setOrderedSections((prev) =>
97-
prev.filter((section) => section.id !== groupId),
88+
(prev ?? product?.groups ?? []).filter(
89+
(section) => section.id !== groupId,
90+
),
9891
);
9992
toast({
10093
title: TOAST_TITLE_SUCCESS,
@@ -135,7 +128,7 @@ export default function ContentPage() {
135128
<ScrollArea className="h-[calc(100vh-180px)]">
136129
{product?.courseId ? (
137130
<ContentSectionsBoard
138-
orderedSections={orderedSections}
131+
orderedSections={resolvedOrderedSections}
139132
setOrderedSections={setOrderedSections}
140133
lessons={product.lessons ?? []}
141134
courseId={product.courseId}

apps/web/graphql/courses/__tests__/logic.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import UserModel from "@models/User";
33
import CourseModel from "@models/Course";
44
import PageModel from "@models/Page";
55
import constants from "@/config/constants";
6-
import { updateCourse } from "../logic";
6+
import { getCourse, updateCourse } from "../logic";
77
import { deleteMedia, sealMedia } from "@/services/medialit";
88

99
jest.mock("@/services/medialit", () => ({
@@ -20,6 +20,10 @@ const UPDATE_COURSE_SUITE_PREFIX = `update-course-${Date.now()}`;
2020
const id = (suffix: string) => `${UPDATE_COURSE_SUITE_PREFIX}-${suffix}`;
2121
const email = (suffix: string) =>
2222
`${suffix}-${UPDATE_COURSE_SUITE_PREFIX}@example.com`;
23+
const GET_COURSE_SUITE_PREFIX = `get-course-${Date.now()}`;
24+
const getCourseId = (suffix: string) => `${GET_COURSE_SUITE_PREFIX}-${suffix}`;
25+
const getCourseEmail = (suffix: string) =>
26+
`${suffix}-${GET_COURSE_SUITE_PREFIX}@example.com`;
2327

2428
describe("updateCourse", () => {
2529
let testDomain: any;
@@ -226,3 +230,89 @@ describe("updateCourse", () => {
226230
);
227231
});
228232
});
233+
234+
describe("getCourse", () => {
235+
let testDomain: any;
236+
let adminUser: any;
237+
238+
beforeAll(async () => {
239+
testDomain = await DomainModel.create({
240+
name: getCourseId("domain"),
241+
email: getCourseEmail("domain"),
242+
});
243+
244+
adminUser = await UserModel.create({
245+
domain: testDomain._id,
246+
userId: getCourseId("admin-user"),
247+
email: getCourseEmail("admin"),
248+
name: "Admin User",
249+
permissions: [constants.permissions.manageAnyCourse],
250+
active: true,
251+
unsubscribeToken: getCourseId("unsubscribe-admin"),
252+
purchases: [],
253+
});
254+
});
255+
256+
beforeEach(async () => {
257+
await CourseModel.deleteMany({ domain: testDomain._id });
258+
});
259+
260+
afterAll(async () => {
261+
await CourseModel.deleteMany({ domain: testDomain._id });
262+
await UserModel.deleteMany({ domain: testDomain._id });
263+
await DomainModel.deleteOne({ _id: testDomain._id });
264+
});
265+
266+
it("returns groups sorted by rank", async () => {
267+
const groupId1 = getCourseId("group-1");
268+
const groupId2 = getCourseId("group-2");
269+
const groupId3 = getCourseId("group-3");
270+
const course = await CourseModel.create({
271+
domain: testDomain._id,
272+
courseId: getCourseId("course"),
273+
title: getCourseId("course-title"),
274+
creatorId: adminUser.userId,
275+
groups: [
276+
{
277+
_id: groupId2,
278+
name: "Group 2",
279+
rank: 2000,
280+
collapsed: true,
281+
lessonsOrder: [],
282+
},
283+
{
284+
_id: groupId1,
285+
name: "Group 1",
286+
rank: 1000,
287+
collapsed: true,
288+
lessonsOrder: [],
289+
},
290+
{
291+
_id: groupId3,
292+
name: "Group 3",
293+
rank: 3000,
294+
collapsed: true,
295+
lessonsOrder: [],
296+
},
297+
],
298+
lessons: [],
299+
type: "course",
300+
privacy: "unlisted",
301+
costType: "free",
302+
cost: 0,
303+
slug: getCourseId("course-slug"),
304+
});
305+
306+
const formattedCourse = await getCourse(course.courseId, {
307+
subdomain: testDomain,
308+
user: adminUser,
309+
address: "",
310+
});
311+
312+
expect(formattedCourse?.groups?.map((group: any) => group.id)).toEqual([
313+
groupId1,
314+
groupId2,
315+
groupId3,
316+
]);
317+
});
318+
});

apps/web/graphql/courses/__tests__/reorder-groups.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ describe("reorderGroups", () => {
117117
slug: id("course-slug"),
118118
});
119119

120-
await reorderGroups({
120+
const reorderedCourse = await reorderGroups({
121121
courseId: course.courseId,
122122
groupIds: [groupId3, groupId1, groupId2],
123123
ctx: {
@@ -142,6 +142,14 @@ describe("reorderGroups", () => {
142142
expect(rankById.get(groupId3)).toBe(1000);
143143
expect(rankById.get(groupId1)).toBe(2000);
144144
expect(rankById.get(groupId2)).toBe(3000);
145+
expect(
146+
(updatedCourse?.groups ?? []).map((group: any) =>
147+
group._id.toString(),
148+
),
149+
).toEqual([groupId3, groupId1, groupId2]);
150+
expect(
151+
(reorderedCourse.groups ?? []).map((group: any) => group.id),
152+
).toEqual([groupId3, groupId1, groupId2]);
145153
});
146154

147155
it("rejects duplicate group ids", async () => {

apps/web/graphql/courses/logic.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,20 @@ async function formatCourse(
129129
(course as any).firstLesson = nextLesson;
130130
}
131131

132-
const result = {
133-
...course,
134-
groups: course!.groups?.map((group: any) => ({
132+
const sortedGroups = course!.groups
133+
?.map((group: any) => ({
135134
...group,
136135
id: group._id.toString(),
137-
})),
136+
}))
137+
.sort(
138+
(groupA: any, groupB: any) =>
139+
(groupA.rank ?? Number.MAX_SAFE_INTEGER) -
140+
(groupB.rank ?? Number.MAX_SAFE_INTEGER),
141+
);
142+
143+
const result = {
144+
...course,
145+
groups: sortedGroups,
138146
paymentPlans,
139147
};
140148
return result;
@@ -1004,23 +1012,21 @@ export const reorderGroups = async ({
10041012
throw new Error(responses.invalid_input);
10051013
}
10061014

1007-
const rankByGroupId = new Map<string, number>();
1008-
groupIds.forEach((groupId, index) => {
1009-
rankByGroupId.set(groupId, (index + 1) * GROUP_RANK_GAP);
1010-
});
1011-
1012-
const updatedGroups = (course.groups ?? []).map((group) => {
1015+
const plainGroupsById = new Map<string, any>();
1016+
(course.groups ?? []).forEach((group) => {
10131017
const plainGroup =
10141018
typeof (group as any).toObject === "function"
10151019
? (group as any).toObject()
10161020
: { ...group };
10171021

1018-
return {
1019-
...plainGroup,
1020-
rank: rankByGroupId.get(group.id) ?? group.rank,
1021-
};
1022+
plainGroupsById.set(group.id, plainGroup);
10221023
});
10231024

1025+
const updatedGroups = groupIds.map((groupId, index) => ({
1026+
...plainGroupsById.get(groupId),
1027+
rank: (index + 1) * GROUP_RANK_GAP,
1028+
}));
1029+
10241030
await CourseModel.updateOne(
10251031
{
10261032
domain: ctx.subdomain._id,

0 commit comments

Comments
 (0)