fix: sequential progression bug in course with ordered contents (#889)
* fix: sequential progression bug in course with ordered contents * test(utils): add course progression tests and unify navigation ordering Reuse flattenNavigableItems in getOrderedNavigableContent so next/previous navigation follows the same section-aware order as progression locks. Add vitest coverage for grouped multi-section flattening and sequential access rules. Co-authored-by: Rotimi Best <[email protected]> * fix: address PR review comments for course progression Simplify grouped flatten ordering, only block progression for lessons and exercises explicitly, integrate flattenNavigableItems in the sequential access test, and include @cio/core watch in api:dev:full. ---------
This commit is contained in:
@@ -2,6 +2,7 @@ import type { Course, CourseContentItem, CourseContentSection } from './types';
|
||||
import { ExerciseIcon, LessonIcon } from '@cio/ui/custom/moving-icons';
|
||||
|
||||
import { ContentType as CourseContentType } from '@cio/utils/constants/content';
|
||||
import { flattenNavigableItems } from '@cio/utils/functions/course-progression';
|
||||
import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
|
||||
|
||||
export type ContentItem = CourseContentItem;
|
||||
@@ -94,8 +95,9 @@ export function getCourseProgress(course: Course | null): ContentProgress {
|
||||
|
||||
export function getOrderedNavigableContent(course: Course | null): ContentItem[] {
|
||||
const content = getCourseContent(course);
|
||||
const items = content.grouped ? content.sections.flatMap((s) => s.items) : content.items;
|
||||
return items.filter((item) =>
|
||||
const flattenedItems = flattenNavigableItems(content);
|
||||
|
||||
return flattenedItems.filter((item) =>
|
||||
NAVIGABLE_CONTENT_TYPES.includes(item.type as (typeof NAVIGABLE_CONTENT_TYPES)[number])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,95 +1,20 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import type { Course, CourseContentItem } from './types';
|
||||
|
||||
type ProgressionLockReason = 'teacher_locked' | 'progression_blocked';
|
||||
|
||||
function sortNavigableItems(items: CourseContentItem[]): CourseContentItem[] {
|
||||
return [...items].sort((left, right) => {
|
||||
const orderDiff = (left.order ?? 0) - (right.order ?? 0);
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
|
||||
const typeDiff = (left.type === ContentType.Lesson ? 0 : 1) - (right.type === ContentType.Lesson ? 0 : 1);
|
||||
if (typeDiff !== 0) return typeDiff;
|
||||
|
||||
return new Date(left.createdAt || 0).getTime() - new Date(right.createdAt || 0).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
function getNavigableItems(course: Course): CourseContentItem[] {
|
||||
if (!course.content) return [];
|
||||
|
||||
const items = course.content.grouped
|
||||
? course.content.sections.flatMap((section) => section.items)
|
||||
: course.content.items;
|
||||
|
||||
return sortNavigableItems(
|
||||
items.filter((item) => item.type === ContentType.Lesson || item.type === ContentType.Exercise)
|
||||
);
|
||||
}
|
||||
|
||||
function lessonBlocksProgression(policy: string | null | undefined): boolean {
|
||||
return policy !== 'none';
|
||||
}
|
||||
|
||||
function isItemProgressComplete(item: CourseContentItem): boolean {
|
||||
return Boolean(item.isComplete);
|
||||
}
|
||||
|
||||
function computeProgressionAccess(
|
||||
navigableItems: CourseContentItem[],
|
||||
progressionMode: 'free' | 'sequential'
|
||||
): Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }> {
|
||||
const accessById = new Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>();
|
||||
let priorBlockingComplete = true;
|
||||
|
||||
for (const item of navigableItems) {
|
||||
const teacherLocked = !(item.isUnlocked ?? true);
|
||||
const progressionLocked = progressionMode === 'sequential' && !priorBlockingComplete;
|
||||
|
||||
let accessible = !teacherLocked && !progressionLocked;
|
||||
let lockReason: ProgressionLockReason | null = null;
|
||||
|
||||
if (teacherLocked) {
|
||||
accessible = false;
|
||||
lockReason = 'teacher_locked';
|
||||
} else if (progressionLocked) {
|
||||
accessible = false;
|
||||
lockReason = 'progression_blocked';
|
||||
}
|
||||
|
||||
accessById.set(item.id, { accessible, lockReason });
|
||||
|
||||
const complete = isItemProgressComplete(item);
|
||||
const blocks = item.type === ContentType.Lesson ? lessonBlocksProgression(item.completionPolicy ?? 'manual') : true;
|
||||
|
||||
if (blocks && !complete) {
|
||||
priorBlockingComplete = false;
|
||||
}
|
||||
}
|
||||
|
||||
return accessById;
|
||||
}
|
||||
|
||||
function annotateContentItem(
|
||||
item: CourseContentItem,
|
||||
accessById: Map<string, { accessible: boolean; lockReason: ProgressionLockReason | null }>
|
||||
): CourseContentItem {
|
||||
const access = accessById.get(item.id);
|
||||
if (!access) return item;
|
||||
|
||||
return {
|
||||
...item,
|
||||
accessible: access.accessible,
|
||||
lockReason: access.lockReason
|
||||
};
|
||||
}
|
||||
import {
|
||||
annotateContentItem,
|
||||
computeProgressionAccess,
|
||||
flattenNavigableItems
|
||||
} from '@cio/utils/functions/course-progression';
|
||||
import type { Course } from './types';
|
||||
|
||||
/** Recomputes student progression locks on in-memory course content after completion changes. */
|
||||
export function syncProgressionAccessToCourse(course: Course): Course {
|
||||
if (!course.content) return course;
|
||||
|
||||
const progressionMode = course.metadata?.progressionMode === 'sequential' ? 'sequential' : 'free';
|
||||
const accessById = computeProgressionAccess(getNavigableItems(course), progressionMode);
|
||||
const navigableItems = flattenNavigableItems(course.content);
|
||||
const accessById = computeProgressionAccess({
|
||||
navigableItems,
|
||||
progressionMode
|
||||
});
|
||||
|
||||
if (course.content.grouped) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user