diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 315f97c..00d4e5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -160,3 +160,30 @@ jobs: npm run build npm publish working-directory: './frontend-plugin-sample' + + publish_app_to_npm: + runs-on: ubuntu-latest + needs: release + if: github.ref_name == 'main' && needs.release.outputs.released == 'true' + permissions: + contents: read + id-token: write + + steps: + - name: Setup | Checkout Repository on Release Ref + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: './frontend-app-sample/.nvmrc' + + - name: Update the package version and publish + run: | + npm install + npm version ${{ needs.release.outputs.version }} + npm run build + npm publish + working-directory: './frontend-app-sample' diff --git a/CLAUDE.md b/CLAUDE.md index 4107427..815c28c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,8 +14,9 @@ This is a **sample plugin repository** that demonstrates all major Open edX plug **Repository Structure:** - `backend-plugin-sample/` - Django app plugin with models, APIs, events, and filters -- `frontend-plugin-sample/` - React component for MFE slot customization -- `tutor-contrib-sample/` - Tutor plugin for easy deployment +- `frontend-plugin-sample/` - React component plugged in via the legacy `frontend-plugin-framework` (FPF) and `env.config.jsx` +- `frontend-app-sample/` - frontend-base `App` (sibling of `frontend-plugin-sample/`) that registers slot operations on the bundled site +- `tutor-contrib-sample/` - Tutor plugin that installs one or the other depending on tutor-mfe's `FRONTEND_APPS` state - Each directory has comprehensive README.md files with TOCs **When Making Changes:** @@ -24,14 +25,19 @@ This is a **sample plugin repository** that demonstrates all major Open edX plug - Maintain working integration between all plugin types - Keep examples realistic but not overly complex +**Branch context:** +This branch ships *both* frontend stacks side by side: `frontend-plugin-sample/` (legacy FPF, identical to `main`) and `frontend-app-sample/` (frontend-base App). The Tutor plugin registers both unconditionally; each one self-no-ops when its target stack isn't in play. The FPF `PLUGIN_SLOTS` contribution only renders into the legacy learner-dashboard MFE (which tutor-mfe skips when the frontend-base learner-dashboard App is enabled), and the frontend-base App's slot operation targets a slot that only exists when the frontend-base learner-dashboard App is loaded. The operator picks the active path by flipping `apps["learner-dashboard"]["enabled"]` in tutor-mfe's `FRONTEND_APPS` filter. See [Port a Frontend Plugin from frontend-plugin-framework to frontend-base](https://docs.openedx.org/en/latest/site_ops/how-tos/port-frontend-plugin-to-frontend-base.html) for the conceptual differences between the two stacks. + **Key Files and Their Relationships:** - `backend-plugin-sample/openedx_plugin_sample/apps.py` - Plugin registration and Django integration - `backend-plugin-sample/openedx_plugin_sample/signals.py` - Open edX Events handlers - `backend-plugin-sample/openedx_plugin_sample/pipeline.py` - Open edX Filters implementation - `backend-plugin-sample/openedx_plugin_sample/models.py` - CourseArchiveStatus model (business logic) -- `backend-plugin-sample/openedx_plugin_sample/views.py` - REST API endpoints consumed by frontend -- `frontend-plugin-sample/src/plugin.jsx` - React component that replaces course list slot -- `tutor-contrib-sample/tutorsample/plugin.py` - Deployment configuration (currently basic template) +- `backend-plugin-sample/openedx_plugin_sample/views.py` - REST API endpoints consumed by either frontend +- `frontend-plugin-sample/src/plugin.jsx` - Legacy FPF React component (imports from `@edx/frontend-platform`) +- `frontend-app-sample/src/CourseList.tsx` - frontend-base React component, TypeScript (imports from `@openedx/frontend-base`) +- `frontend-app-sample/src/app.tsx` - frontend-base `App` with slot operations targeting the learner-dashboard, TypeScript +- `tutor-contrib-sample/tutorsample/plugin.py` - Tutor plugin: backend pip install + unconditional registration of both frontend paths (FPF via `PLUGIN_SLOTS` and frontend-base via `FRONTEND_APPS` + site-config patches); each path self-no-ops when its target stack isn't active ## Build/Lint/Test Commands - Make sure to set the following so that test output is not too verbose: `export PYTEST_ADDOPTS="--disable-warnings --no-header --tb=short"` @@ -69,6 +75,6 @@ Always run `make quality` and fix issues before creating a PR to ensure consiste ### Open edX Plugin Patterns - **API Development**: Use `perform_create()`/`perform_update()` in viewsets for business logic - **Settings**: Use additive approach for `OPEN_EDX_FILTERS_CONFIG` to avoid plugin conflicts -- **Frontend**: Use Paragon components and `getAuthenticatedHttpClient()` for platform integration +- **Frontend**: Use Paragon components and import `getAuthenticatedHttpClient`/`getSiteConfig` from `@openedx/frontend-base` (not `@edx/frontend-platform`). The package's default export is the frontend-base `App`. - **Events**: Import signal handlers in `apps.py ready()` method for proper registration - **Filters**: Return dictionaries with same parameter names as input, handle all scenarios diff --git a/README.md b/README.md index 8a2f418..a81a0bb 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ This sample plugin showcases the **Open edX Hooks Extension Framework**, which a | **Django App Plugin** | Add models, APIs, views, and business logic | [How to create a plugin app](https://docs.openedx.org/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html) | [`backend-plugin-sample/`](./backend-plugin-sample/) | Adding new functionality, APIs, or data models | | **Events (Signals)** | React to platform events | [Open edX Events Guide](https://docs.openedx.org/projects/openedx-events/en/latest/) | [`backend-plugin-sample/openedx_plugin_sample/signals.py`](./backend-plugin-sample/openedx_plugin_sample/signals.py) | Integrating with external systems, audit logging | | **Filters** | Modify platform behavior | [Using Open edX Filters](https://docs.openedx.org/projects/openedx-filters/en/latest/how-tos/using-filters.html) | [`backend-plugin-sample/openedx_plugin_sample/pipeline.py`](./backend-plugin-sample/openedx_plugin_sample/pipeline.py) | Customizing business logic, URL redirects | -| **Frontend Slots** | Customize MFE interfaces | [Frontend Plugin Slots](https://docs.openedx.org/en/latest/site_ops/how-tos/use-frontend-plugin-slots.html) | [`frontend-plugin-sample/`](./frontend-plugin-sample/) | UI customization, adding new components | +| **Frontend Plugin (FPF)** | Customize MFE interfaces via [frontend-plugin-framework](https://github.com/openedx/frontend-plugin-framework) | [Frontend Plugin Slots](https://docs.openedx.org/en/latest/site_ops/how-tos/use-frontend-plugin-slots.html) | [`frontend-plugin-sample/`](./frontend-plugin-sample/) | UI customization on legacy per-MFE images | +| **Frontend App (frontend-base)** | Customize MFE interfaces via the [frontend-base](https://github.com/openedx/frontend-base) Apps model | [tutor-mfe Frontend-base site](https://github.com/overhangio/tutor-mfe#frontend-base-site) | [`frontend-app-sample/`](./frontend-app-sample/) | UI customization on the bundled frontend-base site | | **Brand Packages** | Customize theming | [Open edX Brand Package Interface](https://github.com/openedx/brand-openedx) | [`brand-sample/`](./brand-sample/) | UI theming | | **Tutor Plugin** | Deploy plugins easily | [Tutor Plugin Development](https://docs.tutor.edly.io/) | [`tutor-contrib-sample/`](./tutor-contrib-sample/) | Simplified deployment and configuration | @@ -49,14 +50,26 @@ This sample plugin showcases the **Open edX Hooks Extension Framework**, which a tutor mounts add "$PWD/backend-plugin-sample" # Rebuild image, run migrations, reboot containers: -tutor dev launch - -# Frontend Plugin Setup (for learner-dashboard MFE development) -# Add env.config.jsx and module.config.js (see frontend-plugin-sample/README.md) -# Then, install and run. +tutor dev launch + +# Frontend setup. The tutor-contrib-sample plugin registers both frontend +# siblings. +# +# Legacy FPF: set up env.config.jsx and module.config.js, +# then install and run the learner-dashboard MFE (see +# frontend-plugin-sample/README.md for details). cd path/to/frontend-app-learner-dashboard && npm ci && npm run dev +# +# Frontend-base: flip apps["learner-dashboard"]["enabled"] = True +# in your own Tutor plugin, set up a fork of frontend-template-site +# with frontend-app-sample in packages/ (see frontend-app-sample/README.md +# for details), then: +cd path/to/frontend-site && npm i && npm run dev:packages ``` +> [!NOTE] +> This carries both the legacy `frontend-plugin-sample/` (frontend-plugin-framework) and a `frontend-app-sample/` sibling that targets tutor-mfe's [frontend-base site](https://github.com/overhangio/tutor-mfe#frontend-base-site). The Tutor plugin in `tutor-contrib-sample/` registers both. For the conceptual differences between the two stacks, see [Port a Frontend Plugin from frontend-plugin-framework to frontend-base](https://docs.openedx.org/en/latest/site_ops/how-tos/port-frontend-plugin-to-frontend-base.html). + ### Option 2: Development without Tutor ```bash @@ -91,7 +104,8 @@ Use the table above to identify which type of plugin matches your needs. You can - **Backend**: Start with [`backend-plugin-sample/openedx_plugin_sample/apps.py`](./backend-plugin-sample/openedx_plugin_sample/apps.py) to understand plugin registration - **Events**: Examine [`backend-plugin-sample/openedx_plugin_sample/signals.py`](./backend-plugin-sample/openedx_plugin_sample/signals.py) for event handling patterns - **Filters**: Review [`backend-plugin-sample/openedx_plugin_sample/pipeline.py`](./backend-plugin-sample/openedx_plugin_sample/pipeline.py) for behavior modification -- **Frontend**: Explore [`frontend-plugin-sample/src/plugin.jsx`](./frontend-plugin-sample/src/plugin.jsx) for UI customization +- **Frontend (FPF)**: Explore [`frontend-plugin-sample/src/plugin.jsx`](./frontend-plugin-sample/src/plugin.jsx) for the legacy React component plugged into `env.config.jsx` +- **Frontend (frontend-base)**: Explore [`frontend-app-sample/src/app.tsx`](./frontend-app-sample/src/app.tsx) for the App declaration and [`frontend-app-sample/src/CourseList.tsx`](./frontend-app-sample/src/CourseList.tsx) for the React component ### 4. Run This Sample Follow the [Quick Start Guide](#quick-start-guide) to see everything working together. @@ -115,15 +129,22 @@ sample-plugin/ │ │ ├── settings/ # Plugin settings configuration │ │ └── urls.py # URL routing │ └── tests/ # Comprehensive test examples -├── frontend-plugin-sample/ -│ ├── README.md # Frontend plugin detailed guide +├── frontend-plugin-sample/ # Legacy FPF version (env.config.jsx) +│ ├── README.md +│ ├── src/ +│ │ ├── plugin.jsx # React component for the FPF slot +│ │ └── index.jsx # Named exports +│ └── package.json +├── frontend-app-sample/ # frontend-base version (site config; TypeScript) +│ ├── README.md │ ├── src/ -│ │ ├── plugin.jsx # React component for MFE slot -│ │ └── index.jsx # Export configuration -│ └── package.json # NPM package configuration -└── tutor-contrib-sample/ +│ │ ├── CourseList.tsx # React component +│ │ ├── app.tsx # frontend-base App with slot operations +│ │ └── index.ts # Default-exports the App +│ └── package.json +└── tutor-contrib-sample/ # Registers both siblings; each self-no-ops when inactive ├── README.md # Tutor deployment guide - └── sample.py # Tutor plugin configuration + └── tutorsample/plugin.py # Tutor plugin configuration ``` ## Development Workflows diff --git a/frontend-app-sample/.gitignore b/frontend-app-sample/.gitignore new file mode 100644 index 0000000..81bf941 --- /dev/null +++ b/frontend-app-sample/.gitignore @@ -0,0 +1,199 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# JS Git Ignore +.DS_Store +.eslintcache +.idea +*.swp +*.swo +node_modules +npm-debug.log +coverage +env.config.* + +dist/ +logs + +### Editors ### +*~ +/temp +/.vscode + +# Local package dependencies +module.config.js + +# Local environment overrides +.env.private diff --git a/frontend-app-sample/.nvmrc b/frontend-app-sample/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/frontend-app-sample/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/frontend-app-sample/README.md b/frontend-app-sample/README.md new file mode 100644 index 0000000..ae4545a --- /dev/null +++ b/frontend-app-sample/README.md @@ -0,0 +1,167 @@ +# Frontend App Implementation Guide (frontend-base) + +This directory contains a sample [frontend-base](https://github.com/openedx/frontend-base) **app** that customizes the learner-dashboard's course list with archive/unarchive functionality. + +It is published to npm as `@openedx/frontend-app-sample` and plugged into a frontend-base site via the site config. + +> [!NOTE] +> This is the frontend-base sibling of [`../frontend-plugin-sample/`](../frontend-plugin-sample/), which targets the legacy `frontend-plugin-framework` (FPF) flow. The Tutor plugin in [`../tutor-contrib-sample/`](../tutor-contrib-sample/) registers both. 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) +- [How a frontend-base app works](#how-a-frontend-base-app-works) +- [Code Walkthrough](#code-walkthrough) +- [Local Development](#local-development) +- [Deployment with Tutor](#deployment-with-tutor) +- [Customizing this Example](#customizing-this-example) +- [Migrating from frontend-plugin-framework](#migrating-from-frontend-plugin-framework) +- [Troubleshooting](#troubleshooting) + +## Overview + +The plugin demonstrates how to extend an Open edX MFE without forking it: + +- **`CourseList.tsx`** is a React component that fetches archive state from the backend plugin's API and renders an archive-aware version of the learner-dashboard course list. +- **`app.tsx`** exports a frontend-base `App` that registers a single `widgetReplace` operation against the learner-dashboard's `CourseList` slot. + +Once the site config includes `sampleApp` in its `apps` list, the operation is applied automatically wherever the slot renders. No edits to the learner-dashboard repo are required. + +**Official Documentation:** +- [frontend-base on GitHub](https://github.com/openedx/frontend-base) +- [tutor-mfe: Frontend-base site](https://github.com/overhangio/tutor-mfe#frontend-base-site) (covers `FRONTEND_APPS`, `FRONTEND_SLOTS`, and app-package patterns) +- [Available slots in frontend-app-learner-dashboard](https://github.com/openedx/frontend-app-learner-dashboard/tree/master/src/slots) + +## How a frontend-base app works + +An `App` is a TypeScript/JavaScript object exported from an npm package. Its key fields: + +| Field | Purpose | +| --- | --- | +| `appId` | A globally-unique reverse-DNS string identifying the App. | +| `slots` | An array of `SlotOperation` objects that mutate the site's slots when the App is registered. | +| `routes` | (Optional) Routes the App contributes to the shell. Not used in this sample. | +| `providers` | (Optional) React context providers wrapped around the shell. | +| `config` | (Optional) Default values for app-specific runtime config keys. | + +A `SlotOperation` describes one mutation: + +```js +{ + slotId: 'org.openedx.frontend.slot.learnerDashboard.courseList.v1', + id: 'org.openedx.frontend.widget.sample.courseList.v1', + op: WidgetOperationTypes.REPLACE, + relatedId: 'defaultContent', + component: CourseList, +} +``` + +| Field | Purpose | +| --- | --- | +| `slotId` | Which slot to target. Each MFE documents its slot IDs under `src/slots/`. | +| `id` | A unique reverse-DNS id for this widget. | +| `op` | One of `APPEND`, `PREPEND`, `INSERT_BEFORE`, `INSERT_AFTER`, `REPLACE`, `REMOVE`. | +| `relatedId` | The widget to anchor against. `defaultContent` refers to the slot's default rendering. | +| `component` | A React component that receives the slot's props. Use `element` instead for inline JSX. | +| `condition` | (Optional) `{ active: [roleId] }` to scope the op to specific routes. | + +This is **different** from the legacy `frontend-plugin-framework` API, which used `PLUGIN_OPERATIONS.Insert/Hide/Wrap` with a `widget` wrapper and was configured via `env.config.jsx`. See [Migrating from frontend-plugin-framework](#migrating-from-frontend-plugin-framework) below. + +## Code Walkthrough + +### `src/CourseList.tsx` + +The React component. It calls the backend plugin's REST API using frontend-base's auth client and config helpers: + +```jsx +import { getAuthenticatedHttpClient, getSiteConfig } from "@openedx/frontend-base"; + +const client = getAuthenticatedHttpClient(); +const lmsBaseUrl = getSiteConfig().lmsBaseUrl; +``` + +The slot delivers a `courseListData` prop with `visibleList`, `course`, and `courseRun` fields. The component splits courses into active/archived buckets and renders Paragon cards with an archive toggle that PATCHes the backend. + +### `src/app.tsx` + +Declares the App and its single slot operation. The op uses `WidgetOperationTypes.REPLACE` with `relatedId: 'defaultContent'` so the upstream default course list is hidden and ours takes its place. + +### `src/index.ts` + +Re-exports the App as the default and the underlying React component as a named export, so site operators can `addApp(siteConfig, sampleApp)` and tests can mount `` directly. + +## Local Development + +### Develop the App inside a site + +The most ergonomic loop is to wire this package into a frontend-base site as a local workspace package so the site's bundler hot-reloads changes: + +1. Clone [`frontend-template-site`](https://github.com/openedx/frontend-template-site) locally. It is the standard starting point for a frontend-base site. +2. Put a copy of this package (or a bind-mount, but never a symlink) under the site's `packages/` directory, add `"@openedx/frontend-app-sample": "*"` to the site's `package.json` `dependencies`, then run `npm install` in the site root so npm picks it up as a workspace. +3. In the site's `site.config.dev.tsx`: + + ```tsx + import { EnvironmentTypes, SiteConfig, footerApp, headerApp, shellApp } from '@openedx/frontend-base'; + import { learnerDashboardApp } from '@openedx/frontend-app-learner-dashboard'; + import { sampleApp } from '@openedx/frontend-app-sample'; + + const siteConfig: SiteConfig = { + // ... siteId, baseUrl, lmsBaseUrl, etc. ... + environment: EnvironmentTypes.DEVELOPMENT, + apps: [shellApp, headerApp, footerApp, learnerDashboardApp, sampleApp], + }; + + export default siteConfig; + ``` + +4. Run `npm run dev:packages` in the site root. It builds the workspace packages via turbo, watches them, and starts the site dev server in one shot. + +### Develop the App via tutor-mfe + +If you'd rather develop against a Tutor stack, and provided the sibling [`tutor-contrib-sample`](../tutor-contrib-sample/) plugin is installed and enabled: + +```bash +tutor mounts add /path/to/sample-plugin/frontend-app-sample +tutor dev launch +``` + +The `mfe-dev` service bind-mounts the package into the site's workspace and watches for changes. The site is served at `http://apps.local.openedx.io:8080`. + +### Build for publish + +```bash +npm install +npm run build +``` + +The output goes to `dist/`. The package is publishable to npm as `@openedx/frontend-app-sample`. + +## Deployment with Tutor + +In production, the package is installed into the frontend-base site by [`tutor-contrib-sample`](../tutor-contrib-sample/), which: + +1. Adds an entry to `FRONTEND_APPS` so tutor-mfe installs the npm package into the site workspace. +2. Adds an `mfe-site-config-imports` patch to import the App. +3. Adds an `mfe-site-config` patch to call `addApp(siteConfig, sampleApp)`. + +See [`../tutor-contrib-sample/README.md`](../tutor-contrib-sample/README.md) for the full plugin. + +## Customizing this Example + +**Target a different slot:** Replace the `slotId` in `src/app.tsx`. The available slot IDs are documented under each frontend-base app's `src/slots/` directory (e.g., the [learner-dashboard slots](https://github.com/openedx/frontend-app-learner-dashboard/tree/master/src/slots)). + +**Use a different operation:** Swap `WidgetOperationTypes.REPLACE` for `APPEND`, `PREPEND`, `INSERT_BEFORE`, `INSERT_AFTER`, or `REMOVE`. With `APPEND`/`PREPEND`, drop `relatedId` and the default content stays alongside your widget. + +**Scope to specific routes:** Add a `condition: { active: ['org.openedx.frontend.role.learnerDashboard'] }` to the op so it only fires on the learner-dashboard routes. Useful when the same slot is rendered by multiple apps. + +**Register multiple ops:** Push more entries into the App's `slots` array. They can target the same or different slots. + +## Troubleshooting + +**App registers but the slot doesn't change:** Confirm the `slotId` matches the target frontend-base app's slot. Slot IDs changed during the FPF -> frontend-base migration (e.g., `org.openedx.frontend.learner_dashboard.course_list.v1` -> `org.openedx.frontend.slot.learnerDashboard.courseList.v1`). + +**`getConfig is not a function` or similar at runtime:** Your code is still importing from `@edx/frontend-platform`. Swap to `@openedx/frontend-base`. If you can't change the source, install [frontend-base-compat](https://github.com/openedx/frontend-base-compat) instead. + +**App leaks into another MFE's routes:** Add `condition: { active: [roleId] }` to the op, with the role registered by the target app (e.g., `org.openedx.frontend.role.learnerDashboard`). + +**The default course list still renders alongside mine:** Your op is probably `APPEND`/`PREPEND`, which keeps `defaultContent`. Use `REPLACE` with `relatedId: 'defaultContent'` to swap it for yours instead. diff --git a/frontend-app-sample/package.json b/frontend-app-sample/package.json new file mode 100644 index 0000000..bf5b34e --- /dev/null +++ b/frontend-app-sample/package.json @@ -0,0 +1,23 @@ +{ + "name": "@openedx/frontend-app-sample", + "version": "0.0.0-dev", + "description": "Sample frontend-base App that customizes the learner-dashboard course list.", + "repository": "https://github.com/openedx/sample-plugin", + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "dist" + ], + "sideEffects": false, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "clean": "rm -rf dist", + "prepack": "npm run clean && npm run build" + }, + "peerDependencies": { + "@openedx/frontend-base": "^1.0.0-alpha || 0.0.0-dev", + "@openedx/paragon": "^23", + "react": "^18" + } +} diff --git a/frontend-app-sample/src/CourseList.tsx b/frontend-app-sample/src/CourseList.tsx new file mode 100644 index 0000000..52530e1 --- /dev/null +++ b/frontend-app-sample/src/CourseList.tsx @@ -0,0 +1,242 @@ +import { useState } from "react"; +import { getAuthenticatedHttpClient, getSiteConfig } from "@openedx/frontend-base"; +import { + Card, + Container, + Row, + Col, + Badge, + Collapsible, + Button, + Spinner, + Dropdown, + IconButton, + Icon, +} from "@openedx/paragon"; +import { Archive, Unarchive, MoreVert } from "@openedx/paragon/icons"; + +// The shape the learner-dashboard slot delivers. We declare only the fields +// this widget reads; everything else is left out so additions upstream are +// a non-event for our types. +interface CourseData { + cardId: string; + course: { + courseName: string; + courseNumber: string; + bannerImgSrc: string; + socialShareUrl?: string; + shortDescription?: string; + }; + courseRun?: { + courseId: string; + homeUrl?: string; + // Injected into each courseRun by the backend plugin's filter pipeline + // step on the Learner Home /init API response. + isArchivedByLearner?: boolean; + }; +} + +interface CourseListData { + visibleList: CourseData[]; +} + +interface CourseListProps { + courseListData?: CourseListData; +} + +interface ArchiveStatusRecord { + id: number; + course_id: string; + is_archived: boolean; +} + +const CourseList = ({ courseListData }: CourseListProps) => { + // Seed the archived-course set from `courseRun.isArchivedByLearner`, which the + // backend plugin's filter pipeline injects into each courseRun in the Learner + // Home /init API response. This avoids a separate GET to course-archive-status + // on every dashboard load. Local toggles below keep this set in sync without + // a refetch. + const [archivedCourses, setArchivedCourses] = useState>(() => { + const initial = new Set(); + (courseListData?.visibleList || []).forEach((courseData) => { + if (courseData.courseRun?.isArchivedByLearner) { + initial.add(courseData.courseRun.courseId); + } + }); + return initial; + }); + const [loadingStates, setLoadingStates] = useState>(new Map()); + + if (!courseListData || !courseListData.visibleList) { + return
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 ( + + + + + + + {courseData.course.courseName} + + } + subtitle={courseData.course.courseNumber} + actions={ + <> + {isArchived && Archived} + + + + {courseData.course.socialShareUrl && ( + + View Course About Page + + )} + + + + } + /> + + {courseData.course.shortDescription && ( +

+ {courseData.course.shortDescription} +

+ )} +
+ + + +
+ + ); + }; + + 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 # ---------------------------------------------------------------------------