Skip to content

Add frontend-base route tree with stub pages, roles and provides #2188

Closed

Description

@brian-smith-tcril

Part of #1905. Depends on #2187.

Goal: Learning's route tree exists in the frontend-base package and the shell knows about it: one root route at learning, a child route for every URL Learning serves today, each rendering a stub page that names itself as not yet ported, roles on every route, and provides so the shell header's course bar lists Learning's tabs and switches between them client-side. Nothing fetches, no provider mounts, no page is ported.

Why first: routing and roles are the one thing every page port depends on, and they can be verified on their own. With stubs in place, a tester sees the course bar render Learning's tabs and navigate to each one, which proves the route ids, the roles and the provides wiring before any page carries them.

Note

The plan below was generated by Claude (Claude Code) and reviewed before posting.

Claude Plan

Baseline

  • The package at the top of build: fail frontend-base package tests on unexpected console output #2219: routes and slots are empty arrays, Main.tsx (a CurrentAppProvider, a Helmet title and an Outlet) is unused, 2 tests, the suite fails on unexpected console output.
  • The shell's course bar and masquerade bar take the course from the active route's params: CourseTabsNavigation and MasqueradeBar call useParams() for courseId, and the Studio link also reads unitId. Learning's param names are already those, so the routes below keep them.
  • The shell mounts the course bar when a role listed under org.openedx.frontend.provides.courseBarRoles.v1 is active, where ancestor routes' roles count as active; it makes a tab a client-side link when that role's route path prefix-matches the tab's pathname (isClientRoute), and the tab list itself comes from course_metadata as absolute LMS URLs, matched by pathname (findActiveTab). A single role on the root learning route therefore satisfies every check for every tab, which is how instructor dashboard declares its one role under both ids.
  • Legacy Learning never required a signed-in user at initialization (anonymous access to public courses), so no route takes authenticatedLoader here; a page port that needs one adds it.

Route tree

src/routes.tsx exports a RoleRouteObject[] with one root and eleven children, paths relative to the root so the array carries no site basename:

  • Root: path: 'learning', id: 'org.openedx.frontend.route.learning.main', handle.roles: ['org.openedx.frontend.role.learning'], Component: Main (the template's Main.tsx, which is the CurrentAppProvider the shell's per-app config reads through, plus an outlet; the Learning-wide providers land on it with the first page that needs each). learning means "inside the Learning app" and is not what the course bar is given.

  • Two course layout routes under the root, course/:courseId and preview/course/:courseId, each a bare outlet carrying org.openedx.frontend.role.learningCourse: "this route is a course page", the statement a course-bar role makes (frontend-base's ADR 0013 example and instructor dashboard's role both sit on a :courseId route). The course pages are their children; the three non-course pages sit directly under the root, so the shell's course bar and masquerade bar, which read courseId from the route params, mount only where one exists.

  • Page routes, each id: 'org.openedx.frontend.route.learning.<page>', each rendering <StubPage page="..." /> (the page key, which is also the route id's last segment and matches the role's noun where a role exists), with a role only where another app would resolve it to link there (paths below are relative to learning; under the course layouts the course/:courseId and preview/course/:courseId prefixes are the layout's):

    page path(s) under learning role
    course home (outline tab) course/:courseId/home org.openedx.frontend.role.courseOutline
    dates course/:courseId/dates org.openedx.frontend.role.courseDates
    progress course/:courseId/progress, course/:courseId/progress/:targetUserId org.openedx.frontend.role.courseProgress
    discussion course/:courseId/discussion/:path/* none (ported as a redirect, Port the discussion tab to frontend-base as a redirect #2204)
    live course/:courseId/live none (reached only from the course bar, which navigates by the tab URLs the LMS supplies)
    courseware course/:courseId, course/:courseId/:sequenceId, course/:courseId/:sequenceId/:unitId, preview/course/:courseId/:sequenceId, preview/course/:courseId/:sequenceId/:unitId org.openedx.frontend.role.courseware
    course exit course/:courseId/course-end none (reached only from inside courseware)
    access denied course/:courseId/access-denied none (internal state)
    goal unsubscribe goal-unsubscribe/:token none (reached from emailed URLs)
    redirect landing redirect/* none (internal state)
    preferences unsubscribe preferences-unsubscribe/:userToken/:updatePatch? none (reached from emailed URLs)

    Each page is a named array of its route objects written out in full (coursewareRoutes, progressRoutes, ...), spread into its layout's children in table order; the bare course path is the course layout's index route; a page with several paths has several objects sharing its stub and role, with the id on the first. No helper: a port replaces its page's array with real routes, so the explicit shape is the lasting one; react-router ranks a static segment above a param, so dates beats :sequenceId under the course layout as it does on master. The eleven paths are master's DECODE_ROUTES and ROUTES with the leading slash removed; the legacy constants are rewritten here, not moved, so the manifest gets no line.

  • src/constants.ts holds the four page role ids beside appId and learningRole, flat camelCase after catalog's. The four are the pages with a plausible consumer outside Learning, and each is also a tab the course bar lists. Nothing exports them: other apps name the role they resolve as a string, as learner dashboard does for catalog's courses role.

  • src/StubPage.tsx: one component, one line, the page name and "not yet ported", no translation since it never ships. Each port deletes its use; the file goes with the last.

  • No catch-all: the shell's router renders its not-found page for an unmatched path. Not here: the URL-decoding and path-fixes wrappers (they come with the courseware container, Port the courseware container and redirects to frontend-base #2195), providers, data, the header widget, slots.

provides

src/provides.ts, as instructor dashboard's: the learningCourse role in an array under both providesCourseBarRolesId and providesCourseBarMasqueradeRolesId (the shell's docstring form), wired into src/app.ts. It resolves to /learning/course/:courseId, which prefix-matches every tab URL, so the bar client-navigates to every Learning tab. The masquerade bar then renders on any course route when the masquerade endpoint says the user may; a tab that the LMS lists and no app provides is still shown, as a full-page link.

Tests

  • src/routes.test.tsx: one describe per page and one test per path, each rendering the exported array with createMemoryRouter at that path and asserting the stub names the page and lists the params the path captures. A page's block changes with its port (the stub assertion becomes the page's own) rather than being deleted, so the file keeps saying that every URL reaches its page through the real array. No unmatched-path test: without the shell's errorElement the router's default error boundary logs to the console, and the not-found page is the shell's, checked on the dev site.
  • jsdom-environment-with-node-fetch.mjs: jsdom has no Fetch API (Implement the Fetch API jsdom/jsdom#1724, still open) and react-router's data router builds a Request for any route with lazy or a loader, so a jest environment extending jest-environment-jsdom forwards Node's own fetch, Headers, Request, Response, AbortController and AbortSignal into the sandbox. react-router's own suite assigns the same globals in its jest setup (from undici, since a setup file runs inside the sandbox); an environment runs outside it and can hand over the classes Node ships. whatwg-fetch was rejected on react-router's refusal to support a non-spec-compliant polyfill ([Bug]: useActionData returns raw Response when using polyfilled fetch remix-run/react-router#9722). The redirect layers' loaders return a Response and need the same globals.
  • src/app.test.ts grows the provides assertion: the root role under both ids, every child route's declared handle.roles, and a role resolution block with one test per role naming the URL it resolves to, since for the two roles declared on several paths that is the declaration order, which other apps' links depend on.
  • A shared render helper, src/tests/render.tsx, wrapping a memory router and the intl provider, is introduced here since every later page test needs it; the query client joins when the dates port brings the first fetch. src/tests/ is where master keeps its test helpers (and account and profile theirs); the build config excludes it, as account's does, so helpers never ship in dist/, and coverage excludes it, as it does the setup file and the mocks.
  • src/routes.test.tsx also asserts expect(runPageRules(document)).toEqual([]) at every path, in a describe('page rules') of its own: runPageRules and its mainLandmark rule (more than one <main> without hidden is an error) come from fix: move the skip link's focus to the app's main landmark frontend-base#329, first published in 2.0.0-alpha.17, which feat: scaffold the frontend-base package #2217 locks. A page's port moves its path out of the list and asserts the rules in its own route test.
  • No fetch is mocked because nothing fetches; the suite must stay silent under the console hook.

Commits

Two, squashed on merge: feat: add the frontend-base route tree with stub pages, roles and provides, then test: assert the page rules on every route. Checks: npm run lint, npm test, npm run build, npm run build:ci in frontend-base/; the root make validate does not see the package.

Stack

bsmith/frame-routes above bsmith/test-console-noise, opened as a draft.

Manual testing

On the package's dev site against Tutor dev, signed in as a staff user, with the LMS's learning URL pointing at apps.local.openedx.io:2000/learning and tutor's mfe service stopped:

  • /learning/course/<courseId>/home: the shell header and footer render, the course bar lists the course's tabs from course_metadata, and the page body is the course home stub.
  • Each tab in the bar switches to its stub without a full page load (the dev console keeps its earlier lines; the network panel shows no document request).
  • The masquerade bar shows on course routes for staff.
  • A courseware path with sequence and unit ids, a preview path, the progress path with a target user, and the three non-course paths each show their stub.
  • An unmatched path under /learning shows the shell's not-found page with header and footer.

Not in scope

Acceptance

  • Every path in the table renders its stub under the shell; an unmatched path renders the shell's not-found page
  • The shell course bar lists the course's tabs and navigates to Learning's tabs without a full page load; the masquerade bar appears on course routes for staff
  • The route array has no site-basename assumption
  • Route tests cover every path in the table; the suite stays silent
  • Lint, tests and both builds of the package pass; the legacy workflow stays green; npm run dev from the package serves the stubs
  • No manifest line: the route constants are rewritten, not moved

Activity

  1. changed the title [-]Route tree with stub pages, roles and provides[/-] [+]Add frontend-base route tree with stub pages, roles and provides[/+] on Oct 19, 2026
  2. moved this from Todo to In Progress in frontend-baseon Oct 22, 2026
  3. moved this from In Progress to In Review in frontend-baseon Oct 22, 2026
  4. moved this from In Review to Done in frontend-baseon Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions