Loading courses...
;
+ }
+
+ const courses = courseListData.visibleList;
+
+ const activeCourses = courses.filter(
+ (courseData) => {
+ const courseId = courseData.courseRun?.courseId;
+ return !courseId || !archivedCourses.has(courseId);
+ },
+ );
+
+ const archivedCoursesList = courses.filter((courseData) => {
+ const courseId = courseData.courseRun?.courseId;
+ return courseId !== undefined && archivedCourses.has(courseId);
+ });
+
+ const handleArchiveToggle = async (courseId: string, isCurrentlyArchived: boolean) => {
+ setLoadingStates((prev) => new Map(prev).set(courseId, true));
+
+ try {
+ const client = getAuthenticatedHttpClient();
+ const lmsBaseUrl = getSiteConfig().lmsBaseUrl;
+ const url = `${lmsBaseUrl}/sample-plugin/api/v1/course-archive-status/`;
+
+ const listResponse = await client.get<{ results: ArchiveStatusRecord[] }>(url, {
+ params: { course_id: courseId },
+ });
+
+ if (listResponse.data.results.length > 0) {
+ const existingRecord = listResponse.data.results[0];
+ await client.patch(`${url}${existingRecord.id}/`, {
+ is_archived: !isCurrentlyArchived,
+ });
+ } else {
+ await client.post(url, {
+ course_id: courseId,
+ is_archived: !isCurrentlyArchived,
+ });
+ }
+
+ setArchivedCourses((prev) => {
+ const newSet = new Set(prev);
+ if (isCurrentlyArchived) {
+ newSet.delete(courseId);
+ } else {
+ newSet.add(courseId);
+ }
+ return newSet;
+ });
+ } catch (error) {
+ console.error(
+ `Failed to ${isCurrentlyArchived ? "unarchive" : "archive"} course:`,
+ error,
+ );
+ } finally {
+ setLoadingStates((prev) => {
+ const newMap = new Map(prev);
+ newMap.delete(courseId);
+ return newMap;
+ });
+ }
+ };
+
+ const renderCourse = (courseData: CourseData, isArchived = false) => {
+ const courseId = courseData.courseRun?.courseId;
+ const isLoading = courseId !== undefined && loadingStates.get(courseId);
+
+ return (
+
+
+ {activeCourses.map((courseData) => renderCourse(courseData, false))}
+
+
+ {archivedCoursesList.length > 0 && (
+
+
+
+ {archivedCoursesList.map((courseData) =>
+ renderCourse(courseData, true),
+ )}
+
+
+
+ )}
+
+ );
+};
+
+export default CourseList;
diff --git a/frontend-app-sample/src/app.tsx b/frontend-app-sample/src/app.tsx
new file mode 100644
index 0000000..0ef99b8
--- /dev/null
+++ b/frontend-app-sample/src/app.tsx
@@ -0,0 +1,17 @@
+import { App, WidgetOperationTypes } from "@openedx/frontend-base";
+import CourseList from "./CourseList";
+
+const app: App = {
+ appId: "org.openedx.frontend.app.sample",
+ slots: [
+ {
+ slotId: "org.openedx.frontend.slot.learnerDashboard.courseList.v1",
+ id: "org.openedx.frontend.widget.sample.courseList.v1",
+ op: WidgetOperationTypes.REPLACE,
+ relatedId: "defaultContent",
+ component: CourseList,
+ },
+ ],
+};
+
+export default app;
diff --git a/frontend-app-sample/src/index.ts b/frontend-app-sample/src/index.ts
new file mode 100644
index 0000000..0e07985
--- /dev/null
+++ b/frontend-app-sample/src/index.ts
@@ -0,0 +1,5 @@
+import sampleApp from './app';
+import CourseList from './CourseList';
+
+export default sampleApp;
+export { sampleApp, CourseList };
diff --git a/frontend-app-sample/tsconfig.build.json b/frontend-app-sample/tsconfig.build.json
new file mode 100644
index 0000000..ee082b2
--- /dev/null
+++ b/frontend-app-sample/tsconfig.build.json
@@ -0,0 +1,11 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "noEmit": false
+ },
+ "include": [
+ "src/**/*"
+ ]
+}
diff --git a/frontend-app-sample/tsconfig.json b/frontend-app-sample/tsconfig.json
new file mode 100644
index 0000000..3d37e3e
--- /dev/null
+++ b/frontend-app-sample/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "@openedx/frontend-base/tools/tsconfig.json",
+ "include": [
+ "src/**/*"
+ ]
+}
diff --git a/frontend-plugin-sample/README.md b/frontend-plugin-sample/README.md
index f44b193..2fae79e 100644
--- a/frontend-plugin-sample/README.md
+++ b/frontend-plugin-sample/README.md
@@ -1,7 +1,10 @@
-# Frontend Plugin Implementation Guide
+# Frontend Plugin Implementation Guide (frontend-plugin-framework)
This directory contains a React component that demonstrates how to customize Open edX micro-frontends (MFEs) using the Frontend Plugin Framework. The plugin replaces the default course list in the learner dashboard with a custom implementation that includes course archiving functionality.
+> [!NOTE]
+> This is the legacy FPF sibling of [`../frontend-app-sample/`](../frontend-app-sample/), which targets the newer [`frontend-base`](https://github.com/openedx/frontend-base) stack. The Tutor plugin in [`../tutor-contrib-sample/`](../tutor-contrib-sample/) installs whichever of the two matches your tutor-mfe configuration: this one when the frontend-base learner-dashboard App is *not* enabled, the frontend-base sibling otherwise. See the [porting guide](https://docs.openedx.org/en/latest/site_ops/how-tos/port-frontend-plugin-to-frontend-base.html) for the differences between the two.
+
## Table of Contents
- [Overview](#overview)
diff --git a/tutor-contrib-sample/README.md b/tutor-contrib-sample/README.md
index a4acfa0..3b9f3e0 100644
--- a/tutor-contrib-sample/README.md
+++ b/tutor-contrib-sample/README.md
@@ -247,6 +247,10 @@ def _add_backend_settings(env):
### Frontend Plugin Configuration
+This plugin contributes to both frontend stacks.
+
+**Legacy MFE (frontend-plugin-framework):**
+
```python
# Configure MFE slots
PLUGIN_SLOTS.add_items([
@@ -267,6 +271,32 @@ PLUGIN_SLOTS.add_items([
])
```
+**Frontend-base:**
+
+Registers `@openedx/frontend-app-sample` as a frontend-base app and wires it into the bundled site:
+
+```python
+from tutormfe.hooks import FRONTEND_APPS
+
+@FRONTEND_APPS.add()
+def _add_frontend_app_sample(apps):
+ apps["sample"] = {
+ "npm_package": "@openedx/frontend-app-sample",
+ "npm_version": "^1.0.0",
+ "enabled": True,
+ }
+ return apps
+
+hooks.Filters.ENV_PATCHES.add_item((
+ "mfe-site-config-imports",
+ "import sampleApp from '@openedx/frontend-app-sample';",
+))
+hooks.Filters.ENV_PATCHES.add_item((
+ "mfe-site-config",
+ "addApp(siteConfig, sampleApp);",
+))
+```
+
### Environment-Specific Configuration
```python
@@ -450,4 +480,4 @@ def _validate_plugin_config(config):
return config
```
-This Tutor plugin configuration provides a foundation for deploying the sample plugin in production Open edX environments. The modular approach allows you to adapt the configuration for different deployment scenarios while maintaining consistency across environments.
\ No newline at end of file
+This Tutor plugin configuration provides a foundation for deploying the sample plugin in production Open edX environments. The modular approach allows you to adapt the configuration for different deployment scenarios while maintaining consistency across environments.
diff --git a/tutor-contrib-sample/tutorsample/plugin.py b/tutor-contrib-sample/tutorsample/plugin.py
index df8be4f..8877115 100644
--- a/tutor-contrib-sample/tutorsample/plugin.py
+++ b/tutor-contrib-sample/tutorsample/plugin.py
@@ -1,20 +1,34 @@
"""
Tutor plugin for the Open edX Sample Plugin.
-Installs the backend Django app (openedx-plugin-sample from PyPI) into LMS/CMS
-and configures the frontend MFE slot (from @openedx/plugin-sample on npm) in the
-learner-dashboard.
+Backend
+-------
+Installs the openedx-plugin-sample Django app into LMS/CMS, runs its
+migrations, and (optionally) bind-mounts a local backend-plugin-sample
+checkout into image builds.
+
+Frontend
+--------
+Registers both frontend siblings:
+
+* @openedx/plugin-sample is npm-installed into the legacy MFE images and
+ contributes a PLUGIN_SLOTS entry that swaps in CourseList on the
+ learner-dashboard's course list slot.
+
+* @openedx/frontend-app-sample is registered as a frontend-base app and
+ wired into the bundled site via the mfe-site-config-imports /
+ mfe-site-config patches.
Requirements:
- tutor>=17.0.0
- tutor-mfe (for frontend slot configuration)
+ tutor>=21.0.0
+ tutor-mfe (for both legacy MFE slots and frontend-base site config)
"""
import json
from tutor import hooks
try:
- from tutormfe.hooks import PLUGIN_SLOTS
+ from tutormfe.hooks import FRONTEND_APPS, PLUGIN_SLOTS
_tutormfe_available = True
except ImportError:
_tutormfe_available = False
@@ -34,7 +48,7 @@
# Ensure that *if* backend-plugin-sample is bind-mounted, then it is mapped
# to /mnt/backend-plugin-sample and pip-installed as part of the openedx
-# and openedx-dev image builds
+# and openedx-dev image builds.
hooks.Filters.MOUNTED_DIRECTORIES.add_item(("openedx", "backend-plugin-sample"))
@@ -52,14 +66,15 @@
))
# ---------------------------------------------------------------------------
-# Frontend: Install npm package and configure the learner-dashboard slot
+# Frontend: Register the frontend-base app and configure the legacy MFE slot
# ---------------------------------------------------------------------------
# Only runs when tutor-mfe is installed, so the plugin degrades gracefully
# if someone uses this plugin without the MFE plugin.
# ---------------------------------------------------------------------------
if _tutormfe_available:
- # Step 1: Install the npm package into all MFE images.
+ # ---- legacy FPF -----------------------------------------------------
+ # Install the npm package into all MFE images.
# Ideally this would use mfe-dockerfile-post-npm-install-learner-dashboard
# to scope installation to only the MFE that needs it, but env.config.jsx
# is a single shared file rendered for all MFEs. The buildtime import below
@@ -70,7 +85,7 @@
"RUN npm install @openedx/plugin-sample",
))
- # Step 2: Import the CourseList component in the MFE env config so it is
+ # Import the CourseList component in the MFE env config so it is
# in scope when the plugin slot configuration is evaluated at runtime.
# The mfe-env-config-buildtime-imports patch injects import statements
# into the generated env.config.jsx file.
@@ -79,7 +94,7 @@
"import { CourseList } from '@openedx/plugin-sample';",
))
- # Step 3: Configure the course list plugin slot.
+ # Configure the course list plugin slot.
# - Hide the default CourseList that ships with the learner-dashboard.
# - Insert our custom CourseList that adds archive/unarchive functionality.
#
@@ -105,6 +120,36 @@
}""",
))
+ # ---- frontend-base --------------------------------------------------
+ # Register @openedx/frontend-app-sample with tutor-mfe so the site's
+ # npm install picks it up. The FRONTEND_APPS filter is what tutor-mfe
+ # iterates over when generating the site's package.json and site config.
+ @FRONTEND_APPS.add()
+ def _add_frontend_app_sample(apps):
+ apps["sample"] = {
+ "npm_package": "@openedx/frontend-app-sample",
+ "npm_version": "*",
+ "enabled": True,
+ }
+ return apps
+
+ # Import the frontend-app-sample App in the generated site config so it
+ # is in scope when addApp() is called. The mfe-site-config-imports patch
+ # injects import statements into the generated site.config.*.tsx file.
+ hooks.Filters.ENV_PATCHES.add_item((
+ "mfe-site-config-imports",
+ "import sampleApp from '@openedx/frontend-app-sample';",
+ ))
+
+ # Register the App on the bundled site via addApp(). The App's own
+ # slot operations (defined in frontend-app-sample/src/app.tsx) target
+ # the frontend-base learner-dashboard's course list slot; they are
+ # inert if that App isn't enabled in the operator's FRONTEND_APPS.
+ hooks.Filters.ENV_PATCHES.add_item((
+ "mfe-site-config",
+ "addApp(siteConfig, sampleApp);",
+ ))
+
# ---------------------------------------------------------------------------
# Brand: Override Paragon theme CSS with brand-sample
# ---------------------------------------------------------------------------