Skip to content

@msflib/react-courses

Standalone courses module built on TanStack Query. Wraps the LMS backend's courses module — browsing/searching courses, enrolling, and managing a course's topics and topic items (lessons, assignments, quizzes, etc.), including per-learner progress tracking.

Install

pnpm add @msflib/react-courses

Peer deps

  • react >= 18
  • @tanstack/react-query >= 5

Usage

Wrap your app with the module Provider inside your QueryClientProvider.

In Next.js App Router, Provider must be in a client component ("use client").

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CoursesProvider, useCourses } from '@msflib/react-courses';

const queryClient = new QueryClient();

<QueryClientProvider client={queryClient}>
  <CoursesProvider
    options={{ requireAuth: true, isAuthenticated: true, listParams: { limit: 20 } }}
  >
    <CourseCatalog />
  </CoursesProvider>
</QueryClientProvider>;

function CourseCatalog() {
  const { courses, myCourses, loading } = useCourses();
  return null;
}

Browsing and enrolling

courses (admin/catalog listing) and myCourses (the current user's enrolled courses) are both fetched automatically on mount. searchCourses runs an on-demand filtered search without touching either cached list.

import { useCourses } from '@msflib/react-courses';

function CourseSearch() {
  const { searchCourses, registerCourse } = useCourses();

  async function findAndEnroll() {
    const results = await searchCourses({ title: 'Intro', tag_id: 3 });
    const course = results[0];
    if (course) {
      await registerCourse(course.id, { registration_key: 'ABC123' });
    }
  }

  return null;
}

SSO-based enrollment (e.g. a learner arriving via a signed link) uses ssoRegisterCourse instead, trading a registration_key for a token.

Managing a course's content

A course is made of topics, each with ordered items (lessons, assignments, quizzes, projects). createCourse/updateCourse and addTopicItem/updateTopicItem send multipart/form-data — build the FormData yourself (they accept an optional file upload alongside the regular fields).

import { useCourses } from '@msflib/react-courses';

function CourseEditor({ courseId }: { courseId: number }) {
  const { addTopics, addTopicItem } = useCourses();

  async function addWeekOne() {
    const [topic] = await addTopics(courseId, [
      { title: 'Week 1', label: 'week-1', order: 0 },
    ]);

    const formData = new FormData();
    formData.append('title', 'Welcome video');
    formData.append('type', 'lesson');
    formData.append('media_source', 'external');
    formData.append('duration', '600');
    formData.append('order', '0');
    formData.append('url', 'https://example.com/video.mp4');

    await addTopicItem(topic.id, formData);
  }

  return null;
}

Tracking progress

updateItemProgress records a learner's progress against a specific topic item (status, score, timestamps) — this is what a learner-facing player would call as the user starts/completes an item.

import { useCourses } from '@msflib/react-courses';

function LessonPlayer({ courseId, topicId, itemId }: { courseId: number; topicId: number; itemId: number }) {
  const { updateItemProgress } = useCourses();

  async function onComplete() {
    await updateItemProgress(courseId, topicId, itemId, {
      status: 'completed',
      completed_at: new Date().toISOString(),
      score: 100,
    });
  }

  return null;
}

The item's progress field (present on the learner-facing me/detail responses, absent on the admin catalog) reflects this after a refetch.

Standalone hooks

useCourse(courseId) fetches a single course outside the provider's cached list/me queries — handy for a course detail page that shouldn't wait on the catalog or "my courses" fetch.

import { useCourse } from '@msflib/react-courses';

function CoursePage({ courseId }: { courseId: number }) {
  const { data: course, isPending } = useCourse(courseId);
  return null;
}

Endpoints

Configure paths through configureApplication({ endpoints: { courses } }). All routes are path-scoped by workspace (/{workspace_slug}/courses/...).

Key Default path Methods / routes
courses /courses base path used for scoping/matching

Routes built off courses:

Route Method Context method
${courses} GET listCourses / courses
${courses} POST (multipart) createCourse
${courses}/me GET listMyCourses / myCourses
${courses}/search GET searchCourses
${courses}/sso-register POST ssoRegisterCourse
${courses}/{course_id} GET getCourse
${courses}/{course_id} PUT (multipart) updateCourse
${courses}/{course_id} DELETE deleteCourse
${courses}/{course_id}/register POST registerCourse
${courses}/{course_id}/topics POST addTopics
${courses}/topics/{topic_id} PUT updateTopic
${courses}/topics/{topic_id} DELETE deleteTopic
${courses}/topics/{topic_id}/items POST (multipart) addTopicItem
${courses}/topics/items/{item_id} PUT (multipart) updateTopicItem
${courses}/topics/items/{item_id} DELETE deleteTopicItem
${courses}/progress/{course_id}/topics/{topic_id}/items/{item_id} PUT updateItemProgress

Notes: - All 16 endpoints require an authenticated session (OAuth2PasswordBearer) except search, which is open. - createCourse, updateCourse, addTopicItem, and updateTopicItem send multipart/form-data; every other write sends JSON. - Any mutation that changes course/topic/item state invalidates both the courses and myCourses caches, since either view could be affected.