Skip to content

refactor: port the dates tab to frontend-base - #2234

Open
brian-smith-tcril wants to merge 5 commits into
bsmith/frame-routesfrom
bsmith/dates-tab
Open

brian-smith-tcril wants to merge 5 commits into
bsmith/frame-routesfrom
bsmith/dates-tab

Conversation

@brian-smith-tcril

@brian-smith-tcril brian-smith-tcril commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The Dates tab now runs in Learning's frontend-base package: at /learning/course/:courseId/dates a learner sees the course's dates timeline and the suggested-schedule alerts, inside the ported tab-page frame (loading, errors, access-denied redirects, the enrollment and logistration alerts, user messages, notices and toasts). It is the first page port, so it also brings the course-home data module and sets the test conventions later ports follow. No user-facing change on master: the legacy tree under src/ is untouched.

Part of #1905. Resolves #2189. Stacked above #2221.

Note

For reviewers: each commit adds the ported files under frontend-base/src/ as new files, so git show cannot show what changed from master. I've been using the following script to see each commit's diff against master, based on frontend-base/manifest.tsv.

port-diff
#!/usr/bin/env bash
# A patch of the files a commit changed under frontend-base/src, as they stand at that commit,
# against their legacy files in master, per the provenance manifest at that commit.
#
#   port-diff <rev> [-C <repo>] [--master <ref>] [-o <file.patch>]
#
# A file with a manifest line is diffed against master's file at the legacy path; a file
# without one has no legacy file and shows as added. Deleted files are not listed.
# Files identical to their legacy file are left out of the patch and named on stderr.
# A merge (several legacy files for one target) stops the script: there is no single base to show.
# Defaults: -C ., --master upstream/master, the patch to stdout.
set -euo pipefail
repo=.; master=upstream/master; out=
args=()
while [ $# -gt 0 ]; do
  case "$1" in
    -C) repo=$2; shift 2 ;;
    --master) master=$2; shift 2 ;;
    -o) out=$2; shift 2 ;;
    -h|--help) sed -n '2,11p' "$0"; exit 0 ;;
    *) args+=("$1"); shift ;;
  esac
done
[ ${#args[@]} -eq 1 ] || { echo "usage: port-diff <rev> [-C <repo>] [--master <ref>] [-o <file.patch>]" >&2; exit 2; }
rev=${args[0]}
manifest=$(git -C "$repo" show "$rev:frontend-base/manifest.tsv") || { echo "no manifest at $rev" >&2; exit 2; }

tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/master" "$tmp/port"

while IFS= read -r target; do
  [ -n "$target" ] || continue
  rel=${target#frontend-base/}
  legacy=$(printf '%s\n' "$manifest" | awk -F'\t' -v t="$target" '$2 == t { print $1 }')
  if [ "$(printf '%s' "$legacy" | grep -c '')" -gt 1 ]; then
    echo "$target is a merge of several legacy files:" >&2; printf '  %s\n' $legacy >&2; exit 1
  fi
  mkdir -p "$tmp/port/$(dirname "$rel")"
  git -C "$repo" show "$rev:$target" > "$tmp/port/$rel"
  if [ -n "$legacy" ]; then
    mkdir -p "$tmp/master/$(dirname "$rel")"
    git -C "$repo" show "$master:$legacy" > "$tmp/master/$rel"
    cmp -s "$tmp/master/$rel" "$tmp/port/$rel" && echo "identical to $legacy: $target" >&2
  fi
done < <(git -C "$repo" diff-tree --no-commit-id --name-only -r --diff-filter=d "$rev" -- frontend-base/src)

patch=$(cd "$tmp" && git diff --no-index --no-prefix master port || [ $? -eq 1 ])
if [ -n "$out" ]; then printf '%s\n' "$patch" > "$out"; else printf '%s\n' "$patch"; fi

What changed

  • Course-home data module. course-home/data/ (API functions, query hooks, outline normalizer, the factories its tests build), utils.ts, data/http-error.ts, course-tabs/utils.ts and generic/ToastContext.tsx, each with its test. The setup file starts frontend-base's mock app with a signed-in user and adds the app configs, as profile and account do. Tests wrap the mock app's real client in axios-mock-adapter, which none of the converted apps do (they replace the client with a per-file fake): frontend-base's MockAuthService hands out a real axios.create() client, master's suites and their axiosMock.history assertions are written for the adapter, and the error-path tests depend on real axios error shapes. src/tests/location.ts stubs window.location. The test site's LMS URLs move to http://localhost:18000, which the ported factories build on. The Pact consumer test and its contract, and the dead upgradeNotificationData factory, are dropped; the outline factories wait for Port the outline tab to frontend-base #2191.
  • The frame. tab-page/ (TabPage, LoadedTabPage, TabWithTimer), the enrollment and logistration alerts, the user-messages and notices providers, PageLoading, shared/access.js, the local-storage helper; Main.tsx mounts the notices, user-messages and toast providers around the outlet. Access-denied redirects are prefixed with the learning role's URL and tested where they are made (shared/access.test.ts, TabPage.test.jsx). What later layers port is stubbed at master's import paths: the instructor toolbar (Wire up the frontend-base masquerade bar #2194), product tours (Port the product tours to frontend-base #2208) and the streak celebration modal (Port the celebrations to frontend-base #2201); TabWithTimer passes through until the exam timer returns (Port special exams to frontend-base #2198). The plugin override registry is dropped (Replace the plugin override registry on frontend-base #2223). LoadedTabPage's content wrapper is Paragon's Container size="xl" in place of master's container-xl, so the page lines up with the shell's header and course bar, and it takes mt-3 for the 1rem master's own course tabs left below them (decision log entries 34 and 35).
  • The dates tab. course-home/dates-tab/ and course-home/suggested-schedule-messaging/, the dates route replacing its stub and loading lazily, and BannerDatesUpgradeSlot as a placeholder until Port slots to frontend-base #2185. The package's style.scss loads the timeline's Day.scss, unscoped until App stylesheets apply site-wide after a visit, and converted apps scope them inconsistently frontend-base#333 settles how apps scope their styles (decision log entry 33). Timeline drops a check its own loop makes always true, and it and UpgradeToCompleteAlert drop a ?? {} that only moved a throw (decision log entry 32). The dates test renders through the route array and asserts frontend-base's page rules at the dates path.
  • Query retry and focus refetch. Learning's queries run on the shell's query client, which has the library's defaults. master's retry policy (no retry on a 4xx) and its refetchOnWindowFocus: false move onto each query through a shared queryDefaults spread, as the converted apps set per-query retry.
  • Query error logging. Main.tsx listens on the shell's query cache and logs each Learning query's final failure, at the level its meta.logStatusAs names, as master's query cache did.

Known differences from master

Testing

At each of the five commits, in frontend-base/: npm ci, lint, the tests and build:ci pass. Lint ran with the package's .jsx files included (openedx/frontend-base#327; decision log entry 20).

Coverage. The package meets its 96.15% line bar at every commit: 97.57% at the data commit, 100% at the top (258 tests, one skipped: the streak-celebration case kept as it.skip under #2201).

Codecov's patch lines. Codecov's first report on this PR listed 21 lines with a miss or a branch no test takes (decision log entry 32):

  • Twelve lines no master test fully covers get a test in the commit that ports their file: getProgressTabData's locked-grades conditions, getProctoringInfoData without a username, courseHomeOutline.ts's || [] and Array.isArray on a block's children, reportTimeZone with no browser time zone, the user-messages provider over a saved value that is not a list, PageLoading with an empty srMessage, and UpgradeToCompleteAlert with no click handler.
  • Three lose their branch: Timeline's if (groupedDates.length), which the loop above it makes always true (its body stays), and the ?? {} after useDatesTabData(...).data in Timeline and UpgradeToCompleteAlert, which only moved a throw to the next line.
  • Six lines keep a branch no test takes. master tests each through a suite a later port brings:
Line Branch no test takes Tested with
course-home/data/api.ts:53 isMasquerading's !data.isStaff, read only when originalUserIsStaff is true OutlineTab.test.jsx, #2191
tab-page/TabPage.tsx:94 a denied learner getAccessDeniedRedirectUrl gives no redirect, as on the outline tab OutlineTab.test.jsx, #2191
course-home/suggested-schedule-messaging/ShiftDatesAlert.tsx:26 the hasEnded = false default, for a render whose hasEnded is undefined OutlineTab.test.jsx, #2191
tab-page/LoadedTabPage.tsx:57 celebrations.streakLengthToCelebrate, read only when the metadata has celebrations the streak celebration, #2201
tab-page/LoadedTabPage.tsx:58 celebrations.streakDiscountEnabled && verifiedMode, likewise the streak celebration, #2201
tab-page/LoadedTabPage.tsx:75 the page title with no tab title, when the metadata's tabs have none for the active slug the live tab's suite, #2203

Manual testing on the package's dev site against tutor dev: the timeline, the three suggested-schedule alerts, shifting dates and its toast, providers across a tab switch, the access redirects, the loading and error states, focus refetch and masquerade. The alerts and the shift-dates toast ran on faked LMS responses (Chrome response overrides), each listed in its box. The record is a comment below, since it does not fit in this body beside the decision log.

Decisions

What was decided while building this layer; the scope, the test conventions, the coverage policy and the query client settings are in #2189.

Full decision log

Decisions — port the dates tab (#2189)

Decisions made while implementing. The scope, the import and config
mapping, the root providers and the test conventions are in #2189's plan
and are not repeated.

  1. The mock user is MockUser with a numeric id. master's test user
    is { userId: 'abc123', username: 'MockUser', roles: [], administrator: false };
    frontend-base's User type requires userId: number, name and
    email. The package's user keeps the username, since later ports'
    tests build URLs from it (product tours, courseware), and takes
    userId: 123, a name and an email to satisfy the type. It lives in
    src/tests/authenticatedUser.ts, imported by the setup file, so a test
    that needs the identity imports it from there rather than from the
    setup file as on master.

  2. Test helpers under src/tests/ are excluded from coverage. With
    collectCoverageFrom: ['src/**/*'], the query-client helpers counted
    as uncovered product lines before any page used them and tripped the
    96.15 threshold. They are test infrastructure like the setup file and
    the mocks, which the template already excludes, so src/tests joins
    coveragePathIgnorePatterns; the build already excludes the directory.
    Amended 2026-10-29: the exclusion moves to feat: add the frontend-base route tree with stub pages, roles and provides #2221, which creates
    src/tests/ (feat: add the frontend-base route tree with stub pages, roles and provides #2221's decision log, entry 5), and the query-client helpers
    arrive with the frame commit, their first user (entry 31).

  3. Caret ranges for the two test dependencies. master pins
    axios-mock-adapter to exactly 2.1.0; the package takes npm's
    default ^2.1.0 (and ^13.2.0 for Pact), the caret convention the
    template and OEP-67 use. The exact pin dates from the dependency's
    arrival with the 2020 data integration tests (eac9bf9c) and was only
    ever moved by renovate (19b8df35, 2024-11-20), which keeps whatever
    range style it finds; no commit names a reason for it.

  4. The Pact consumer test is dropped, with its contract. master's
    src/course-home/data/pact-tests/lmsPact.test.jsx runs in the suite
    and was maintained (last touched by refactor: take tab identity out of the course-home metadata query #2099), but what it produces is
    unconsumed: the contract it writes, src/pacts/frontend-app-learning-lms.json,
    is in merge mode and still carries interactions for courseware
    endpoints whose tests were deleted years ago and an old
    /api/course_home/v1/course_metadata path; nothing publishes or
    verifies it (openedx-platform's one Pact verifier,
    lms/djangoapps/courseware/tests/pacts/verify_pact.py, loads its own
    xblock-handler contract; master has no broker, stub script or CI
    step). The two functions it exercised are covered by api.test.js.
    The test, src/pacts/constants.js and the contract file get dropped
    manifest lines and @pact-foundation/pact is not added; a later layer
    can bring them back from master's history if a provider-side
    verifier appears. The epic's "Pact is a per-port check" stands, with
    the check answered here.

  5. The app bundles a defaultConfig with the keys it reads, at null.
    getAppConfig(appId) returns undefined for an app with no
    defaultConfig, no config and no site commonAppConfig, so
    getAppConfig(appId).EXAMS_BASE_URL threw in the first run of the
    ported data tests, and would throw at runtime on a site that sets
    nothing. Account and profile bundle their keys at null for this
    reason, and null is what master's mergeConfig gave these keys
    (process.env.X || null). The epic's "only real defaults in
    defaultConfig" is read as "a key the app reads, with the value
    master defaulted it to"; defaultConfig sits below
    commonAppConfig, so it cannot shadow a site-wide value the way an
    app's own config would. This layer adds ENABLE_NOTICES and
    EXAMS_BASE_URL; each later layer adds the keys its files read.

  6. The real app is registered from the setup file, not the test site
    config.
    Importing learningApp into site.config.test.tsx made the
    provides ids undefined: frontend-base's initializeMockApp imports
    site.config while frontend-base itself is still loading, so the
    app's imports from @openedx/frontend-base resolved to a half-built
    module. The test site config keeps the template's inline stub (the
    how-to's reason for it), and setupTest.js runs
    mergeSiteConfig({ apps: [learningApp] }) before addAppConfigs(),
    after frontend-base is loaded, which is what profile's app test does
    locally. The stub's config survives the merge, so the test config
    is still where test-only values live.

  7. .env.test becomes the test site config. master's tests ran
    with EXAMS_BASE_URL=http://localhost:18740, LMS_BASE_URL=http://localhost:18000
    and BASE_URL=http://localhost:2000 from .env.test, and the hook
    tests filter requests by the exams-service URL shape. The test site
    config carries those three (lmsBaseUrl, baseUrl, and
    EXAMS_BASE_URL in the stub app's config), the way account's test
    config carries only the keys its tests rely on; later layers add
    theirs as their tests need them.

  8. The factories register from the setup file, and the shared course
    factories come with the course-home ones.
    rosie factories exist
    only once their module runs; master's setup file imports the
    factory index, so does the package's. The course-home factories
    import shared/data/__factories__ (course metadata base, tabs,
    blocks), so those five files port here rather than with courseware;
    the eslint-disable-line import/no-extraneous-dependencies comments
    on their rosie imports go, since the base lint config has no import
    plugin and reports the unknown rule.

  9. The main landmark stays on the loaded tab page; #main-content goes.
    The shell's DefaultLayout wraps the content slot in div#main-content,
    the target of the header's skip link, and renders no main element.
    LoadedTabPage keeps master's <main> and drops id="main-content"
    from its inner div, which would be a second element with the id; the
    template's Main.tsx renders a div instead of its <main> so the
    page's landmark is not nested in another.
    Amended 2026-10-27: since Fixes sidebar navigation not exposed as a separate navigation landmark #2026 master puts the <main> on the inner
    container of non-courseware tabs, around the tab content only, and the
    sequence renders it on courseware (entry 23); the port follows, still
    without the id. With fix: move the skip link's focus to the app's main landmark frontend-base#329 the shell's skip link
    moves focus to the page's <main> element, so the skip target becomes
    the same element as on master; until it ships, the link lands on the
    shell's wrapper.

  10. unitId stays on TabPage and LoadedTabPage. First removed,
    since on master the prop reaches only the instructor toolbar
    (LoadedTabPage.tsx:75); with the toolbar a stub (entry 22) the prop
    threads through to it as on master and the files keep their shape.

  11. defaultProps become default parameters, and null defaults go.
    master's setup file silences React's defaultProps deprecation for
    the whole suite; under the console hook UserMessagesProvider,
    AlertList and Alert are fixed instead. A = null default on a
    .jsx parameter is typed null by tsc (strict, allowJs), which made
    .tsx callers unable to pass a string topic or className or element
    children; those props keep no default (undefined when omitted), and no
    reader tells the two apart (!topic, className, children,
    onClose). dismissible, stacked, customAlerts and customProps
    keep theirs.

  12. Third-party defaultProps: Paragon by a lock regeneration,
    react-fontawesome by an upgrade.
    Paragon 23.23.0's Icon still
    declared defaultProps and was first silenced under
    Support for defaultProps will be removed from function components in a future major release. paragon#3043; feat: update Icon to be ts and remove all stuff paragon#4136 had already converted it and
    shipped in 23.23.1, inside frontend-base's range, so the scaffold
    commit's lock is regenerated instead (the scaffold layer's decision
    log) and no Paragon pattern is silenced. react-fontawesome removed its
    defaultProps in 0.2.1 (its changelog, 2024-05-16), so the package
    takes ^0.2.6, the version profile and account pin, in place of
    master's ^0.1.4 (installed as 0.1.19).

  13. The package declares the dependencies its files import.
    @fortawesome/react-fontawesome, @fortawesome/free-solid-svg-icons
    and prop-types resolved through the parent repo's node_modules
    (prop-types also through frontend-base's own dependency) with no line
    in the package's package.json; the two FontAwesome packages were not
    installed in the package at all. They are declared at master's
    versions apart from react-fontawesome (decision 12):
    free-solid-svg-icons ^5.15.4 (profile and account are on the 6.x
    pack, which changes glyphs; not this port's change),
    fontawesome-svg-core ^6.7.2 (react-fontawesome's peer, and what
    master's lock installs beside the 5.x icons), prop-types
    ^15.8.1. Twenty master files import FontAwesome, two in the dates
    tab, so the dependency arrives with this layer either way.

  14. The render helper keeps an ErrorBoundary where AppProvider
    stood.
    master's wrapper is frontend-platform's AppProvider, whose
    outermost element is its ErrorBoundary, and LoadedTabPage.test.jsx
    depends on it: a render before the metadata loads throws, and the test
    asserts the logError call the boundary makes. frontend-base's
    ErrorBoundary reports the same way, so it takes that place; the rest
    of AppProvider is the shell's (SiteContext, locale) or the helper's
    wrapWithRouter. The query client is created once per render call,
    as on master, not once per render of the wrapper.

  15. Tests of the deferred pieces stay, through the stubs. First cut
    with the pieces; with the stubs (entry 22) master's tests return:
    TabPage.test.jsx keeps its two tour-button cases and
    LoadedTabPage.test.jsx its instructor-toolbar case, since both mock
    the module by path and the stub sits there; LoadedTabPage.test.jsx's
    renders correctly loses the course-tabs assertion (the slot is
    dropped; entry 23 replaces it with the page title); its streak-modal case renders the real modal and is kept
    whole as it.skip under Port the celebrations to frontend-base #2201's URL. TabWithTimer.test.tsx keeps one
    pass-through case, its seven timer and attempt cases returning with
    Port special exams to frontend-base #2198, since the library import cannot be stubbed in place.
    Amended 2026-10-27: those seven cases are not deleted but kept as
    master's file, verbatim, in one block comment under Port special exams to frontend-base #2198's URL,
    after the pass-through case. Neither a stub nor it.skip can hold
    them: they run on master's mockCourseRequests() and its
    specialExams mock from @edx/frontend-lib-special-exams/testing,
    neither of which exists in the package until Port special exams to frontend-base #2198, so a skipped case
    would still fail the CI build's type check over the tests. A block
    comment rather than // lines keeps the text byte-identical (the file
    has no */), so its diff against master is the two comment markers
    and the pass-through case, and Port special exams to frontend-base #2198's restore is deleting those and
    porting the imports. A third form, after stubs and it.skip, for a
    test whose harness cannot exist yet.

  16. Two fixes amended into earlier commits. frontend-base's User
    requires avatar: string, which the CI build's type check over the
    test files (not tsconfig.build.json) reported; the mock user takes
    the value frontend-base's authenticatedLoader.test.ts uses (commit 1).
    ESLint 9 reports the no-return-assign,no-sequences disable in
    courseBlocks.factory.js as unused under the base config, so it goes
    (commit 2).

  17. rosie stays for the port. The factories and their 192 Factory.build
    call sites move as they are, so each test file is rewritten once, after
    the port; the library's maintenance status (last release 2023-11-11)
    and the replacement options are Move the test fixture factories off rosie #2222.

  18. The plugin override registry is dropped, not carried. The frame
    commit first ported generic/plugin-overrides/ and mounted its
    provider on the root route as the faithful port of index.jsx. On a
    frontend-base site no plugin can reach the registry: usePluginOverrides
    is not among the package's exports (learningApp, learningRoutes),
    and until Port slots to frontend-base #2185 restores the slots there is no slot to mount a widget
    in; carrying the provider preserved nothing observable. The four files
    get dropped manifest lines, Main.tsx and the render helper mount
    three providers, the unit (Port the courseware sequence and unit to frontend-base #2196) calls getIFrameUrl directly, and
    Replace the plugin override registry on frontend-base #2223 holds the Slot API replacement (the refactor!: move the plugin override registry off Redux onto React context #2082 review's sketch and the
    survey of other plugin authors).

  19. Font Awesome stays for the port. The frame and dates files render
    their icons through @fortawesome/react-fontawesome as on master, and
    the package declares the three dependencies (decision 13). Replacing
    Font Awesome with Paragon icons is one change across twenty files, with
    a product question on the Creative Commons marks, so it is a follow-up
    after the port rather than a change inside each layer: Replace Font Awesome with Paragon icons #2224.

  20. The package lints its .jsx files itself. frontend-base's base lint
    config names no **/*.jsx pattern, so ESLint 9 never lints .jsx files
    under createLintConfig (openedx lint does not lint .jsx files frontend-base#327, a regression of its
    ESLint 9 upgrade; reproduced in every converted app with .jsx files).
    This layer brings the first eleven. The files: ['src/**/*.jsx'] block
    that lints them is not committed: it stays a local, uncommitted change
    to frontend-base/eslint.config.js, reapplied after every stack
    operation, and
    every commit from this one on is linted with it in place, so the branch
    needs nothing when frontend-base ships the fix. Linting them surfaced
    four findings, fixed in this commit: an airbnb-era
    react/forbid-prop-types disable in AlertList.jsx for a rule the base
    config does not enable, an unused act import and a double blank line
    in LoadedTabPage.test.jsx, and a nameless mocked component in
    TabPage.test.jsx (named LoadedTabPage, as TabWithTimer.test.tsx
    names its).

  21. AlertList gets the tests its FIXME deferred. master's
    AlertList.test.jsx renders the list empty and a 2020 FIXME (Agrendalath/bb 2599 low priority tests #214) says
    the alerts are tested in OutlineTab.test and Course.test because a
    provider for testing would be boilerplate; the shared render wrapper
    has mounted UserMessagesProvider since before that FIXME was written,
    and on the branch neither of those tests exists yet. The file gains a
    renderAlertList(props) helper that renders the list under the shared
    wrapper beside a component capturing the provider's context, and
    returns the provider's own add wrapped in act; each case adds its
    message through that rather than through useAlert from a per-message
    component (review: no invented name, no component per message, no test
    ids; queries are by role and text). Cases: nothing with no message, an
    added message as a role="alert" with its variant class under the
    list's class, the topic filter and a list without a topic, a
    customAlerts component receiving the payload and customProps, and
    dismiss. user-messages line coverage goes from 58.06 to 88.7 against
    master's 89.77; useAlert itself (70%) is exercised by the alert
    hooks once the dates tab renders them.

  22. The frame is redone with stubs instead of removals. Reviewed
    2026-10-26: cutting the toolbar, tours and streak modal out of
    LoadedTabPage.tsx and TabPage.tsx meant their later layers would
    re-add the lines in their own shape, so the effort log now says a
    deferred piece is stubbed at master's import path (effort decision
    37). Five stubs: instructor-toolbar/InstructorToolbar (Wire up the frontend-base masquerade bar #2194),
    product-tours/ProductTours, TourContext and
    newUserCourseHomeTour/LaunchCourseHomeTourButton (Port the product tours to frontend-base #2208),
    shared/streak-celebration/StreakCelebrationModal (Port the celebrations to frontend-base #2201), each a
    TypeScript FC typed with the props master passes (so the host's
    JSX type-checks) that renders nothing, the provider returning its
    children, with the issue URL as its one comment, plus the two index
    re-exports master has. LoadedTabPage.tsx and TabPage.tsx now
    differ from master only in the import rewrites, the dropped shell
    pieces, the redirect prefix and the autofixer's wrapping of one-line
    if bodies. Stubs are new files, not moves, so they get no manifest
    line; the plan's "stubbed here" table is their record.

  23. Rebased on master at 230799eb, with two re-ports folded into the
    frame.
    The frontend-base branch had no commits of its own and was
    fast-forwarded to master (Update browserslist DB #2225, Fixes sidebar navigation not exposed as a separate navigation landmark #2026, refactor: migrate enrollment API calls from v1 to v2 #1938, test: render the landmark tests from #2026 through the current helpers #2229); the stack
    rebased onto it without conflicts. The rebase audit flagged four ported
    files, all in this layer's frame commit, folded in rather than filed
    (effort decision 14 as amended):

    • refactor: migrate enrollment API calls from v1 to v2 #1938: alerts/enrollment-alert/data/api.js and its test post to
      /api/enrollment/v2/enrollment/, the same one-line change.
    • Fixes sidebar navigation not exposed as a separate navigation landmark #2026 and test: render the landmark tests from #2026 through the current helpers #2229: LoadedTabPage.tsx and its test are regenerated
      from master's new versions with the same port edits as before. The
      alerts sit in a plain div; the <main> is the inner container on
      non-courseware tabs and a div on courseware, where the sequence
      renders the <main> (Port the courseware sequence and unit to frontend-base #2196). The id prop goes, since the shell
      owns #main-content (entry 9), and the code comment loses
      id="main-content", which became false. In the test the two
      landmark cases port without their #main-content assertions and the
      non-courseware case without "excludes the tab navigation", whose
      subject, the tabs slot, is dropped (the shell's course bar renders
      the tabs); its title says what is left, "wraps the page children".
      master's title, "wraps only the page children, not the alerts or
      tab navigation", claimed more than it asserted: its assertions were
      the #main-content id, the children inside, and the tabs <nav>
      outside. No assertion checked the alerts, and none could pass or
      fail on them: both alert hooks show only when a private outline is
      cached, and the test seeds only the course metadata, so the alert
      list renders empty. The shorter title drops that untested claim,
      not a check; the alerts still render outside the <main> in
      LoadedTabPage.tsx, as on master.
      renders correctly loses the tabs assertion, which left it asserting
      only absences (no instructor toolbar, no dialog) that an empty render
      also satisfies; it now first waits for the document title the page
      sets from the metadata, "Course | Demonstration Course | Learning
      Test Site" (the active tab's title, the course title, the site
      name), as Main.test.tsx checks its title.
      master's removal of the #main-content flex rules from index.scss
      has nothing to apply: the package has no #main-content styling, and
      index.scss is unported; its layer starts from the post-Fixes sidebar navigation not exposed as a separate navigation landmark #2026 file.
      The Sequence.jsx and CourseOutline.tsx changes land with Port the courseware sequence and unit to frontend-base #2196 and
      Port the courseware navigation sidebar to frontend-base #2197, whose bodies now say so.
  24. frontend-base is vendored from fix: move the skip link's focus to the app's main landmark frontend-base#329 until an
    alpha carries it, and every route test asserts the page rules.

    2026-10-27. Update REV-1512 experiment gated content  #329 adds runPageRules (exported from the package root)
    and its first rule, mainLandmark; its how-to tells apps to assert
    expect(runPageRules(document)).toEqual([]) in route tests once the
    page has rendered. Rather than wait for a release, the package takes
    the whole frontend-base from Update REV-1512 experiment gated content  #329's branch: npm pack of its head
    commit da3af1a1 (alpha.16 plus a docs commit and Update REV-1512 experiment gated content  #329, neither
    touching package.json), packed from a clean dist/ so no output
    of an earlier draft rides along, committed as
    vendor/openedx-frontend-base-da3af1a1.tgz (the package's ignore
    list covers /*.tgz at its root only) and named as the dev
    dependency with file:. The peer range, ^2.0.0-alpha || 0.0.0-dev,
    already admits the packed 0.0.0-dev. Copying the rule into
    src/tests/ was the alternative; the tarball tests against the code
    that will ship, and leaving it is one lock-only commit.
    Placement: two commits between the data module and the frame, since
    the frame is the first commit of this layer that renders ported code
    (the vendor commit, then routes.test.tsx's assertions). The route
    test asserts the rules in a describe('page rules') of its own, one
    case per path the file covers, rather than inside expectStub, so
    the "renders at" titles keep claiming only the route match. The stubs
    each render one <main>, and renderRoutes renders no shell, so the
    document holds only Learning's markup. A page's port moves its paths
    out of that list and asserts the rules in its own route test; the
    dates tab is the first. When an alpha with Update REV-1512 experiment gated content  #329 is published, the
    vendor commit is replaced by a lock-only bump to it and the tarball
    goes.
    Done 2026-10-29: Update REV-1512 experiment gated content  #329 merged 2026-10-27 as ae5ec935, and
    2.0.0-alpha.17, published four minutes later, has that commit as
    its gitHead and exports runPageRules from dist/shell/rules. The
    vendor commit becomes a lock-only bump from 2.0.0-alpha.16 to
    2.0.0-alpha.17; package.json keeps the range it had before the
    vendoring, which admits it, and the tarball is gone from the branch.
    Then, by Brian's call, the bump is not a commit of this layer: it
    folds into feat: scaffold the frontend-base package #2217's scaffold commit (feat: scaffold the frontend-base package #2217's decision log, entry 8), and this
    layer's commits drop to seven, the page rules third.
    Then the page-rules commit moves to feat: add the frontend-base route tree with stub pages, roles and provides #2221 as its second commit
    (feat: add the frontend-base route tree with stub pages, roles and provides #2221's decision log, entry 8): it tests only feat: add the frontend-base route tree with stub pages, roles and provides #2221's route tree, and both
    reasons it sat here, the vendored dependency and the template's
    <main>, are gone.
    Finding, 2026-10-27: placed before the frame, every one of the 17
    cases fails with mainLandmark's error, "The page has 2


    elements without the hidden attribute". Main.tsx keeps the
    template's <main className="d-flex flex-column flex-grow-1"> around
    the outlet from feat: scaffold the frontend-base package #2217, where the route list is empty, and feat: add the frontend-base route tree with stub pages, roles and provides #2221's
    StubPage renders a <main className="p-4"> inside it. The frame
    commit turns Main.tsx's wrapper into a div, which is what makes
    the rule pass from there on.
    The div moves down to feat: scaffold the frontend-base package #2217 (its decision log, entry 9), where the
    root route's element is created, so the frame commit no longer
    touches that line.

  25. The dates commit, in progress (2026-10-27). Made while porting:

    • The dates route is lazy, with a learning-dates chunk name, as
      account, catalog and profile load each page route
      (src/routes.tsx in openedx/frontend-app-account,
      openedx/frontend-app-catalog and openedx/frontend-app-profile, read
      2026-10-27). Imported eagerly, it broke every test that mocks a
      tab-page module by path: setupTest.js registers the app, app.ts
      imports routes.tsx, and the real LoadedTabPage was in the module
      registry before any test file's jest.mock ran.
    • master's access-denied block is not ported (entry 27). The
      request-count case waits for the
      page's title before waiting for fetches to settle, since a lazy
      route has nothing fetching until it loads; the page-rules case waits
      for the same title. TabPage renders the tab only once its data has
      loaded, so the title (messages.title, a level-one heading) arrives
      with the timeline and the <main>, and the wait names no fixture
      date (Sun, May 3, 2020 is the factory's 2020-05-04T02:59:40Z in
      the test's America/New_York).
    • master's request-count case replied with whatever courseMetadata
      the access-denied cases left (enrollment_required), so it measured
      a redirect, not a dates load; the port gives it fresh metadata with
      the file's timezone.
    • "does not render UpgradeToCompleteAlert" waits for the suggested
      schedule header before its absence checks; master's ran before
      the page could have rendered, and under the lazy route the test
      ended mid-load (React's act warning).
    • The two upgrade-button cases stub location.replace with
      mockLocation (entry 26): the buttons call it, jsdom reports the
      navigation as not implemented, and the console hook fails on it.
    • Day and UpgradeToCompleteAlert move defaultProps to default
      parameters (entry 11).
    • classnames is declared (^2.5.1; master pins 2.5.1), entry 13.
    • Day's tooltip renders Paragon's OverlayTrigger, which wraps
      react-bootstrap 1.6.8's, and React 18.3 warns three times from
      inside react-bootstrap: defaultProps on its OverlayTrigger and
      Overlay, and findDOMNode from react-overlays under its
      Overlay. setupTest.js silences the first two under
      Deprecation warnings from react-bootstrap 1.x components that Paragon wraps paragon#4523, filed for warnings from the react-bootstrap
      1.x components Paragon wraps (Support for defaultProps will be removed from function components in a future major release. paragon#3043 and #3744 cover
      Paragon's own components), and the third under
      Deprecated: findDOMNode Used in OverlayTrigger After React 18 Upgrade paragon#3425, which tracks findDOMNode in that trigger.
      Each pattern names the exact message.
    • Lint under the base config, as in entries 7, 16 and 20: the
      autofixer wraps utils.jsx's one-line if bodies and the
      UpgradeToShiftDatesAlert test's render call; badgelist.jsx
      loses an import/prefer-default-export disable for a rule the base
      config does not know; DatesTab.test.jsx loses its unused React
      import.
    • The commit lands without the plan's per-directory thresholds; how
      coverage is held is decided separately, and the plan's Coverage
      section and commit 6 change with that decision.
      Open: the per-directory thresholds
      at master's baseline are not met: course-home/data 81.27 of 99.64,
      alerts/logistration-alert 53.84 of 84.44, generic/user-messages
      88.7 of 89.77, course-home/dates-tab 97.89 of 98.39, global 63.78
      of 96.15. master's baseline is its whole suite, so files whose
      consumers are later tabs (the outline factories and functions, the
      logistration alert) were covered by tests this layer does not port.
  26. Tests stub window.location through mockLocation. jsdom cannot
    navigate (location.replace, assign or a new href reports
    "Not implemented: navigation", which the console hook fails on), and
    its Location members are non-configurable, so a test that stubs one
    swaps the whole object. master does that in eleven files, four ways:
    defineProperty with a copy of the real location plus stubbed members,
    restored in afterEach (TabWithTimer.test, and
    Sequence.specialExams.test, whose stubLocation wraps it);
    defineProperty with a bare { replace } or { reload } and a
    try/finally restore (apiHooks.test, ProgressTab.test,
    EnrollmentAlert.test); delete and assignment, restored at the end
    of the case (OutlineTab.test) or never (NoticesProvider.test,
    CoursewareRedirectLandingPage.test); defineProperty with a bare
    object, never restored (RedirectPage.test,
    MasqueradeWidgetOption.test, useIFrameBehavior.test).
    src/tests/location.ts exports mockLocation(members), which takes the
    first shape, installs { ...window.location, ...members } and returns
    it, and
    restoreLocation, which the setup file runs after each test, so no
    case restores by hand and none leaks. The spread keeps href and the
    other values as plain readable, writable properties (the timer test
    reads the href the exam library writes); a member not passed is
    jsdom's method detached from its Location, which throws when called.

    • The helper comes in commit 2 with apiHooks.test's case, the first
      that stubs navigation; commit 5's EnrollmentAlert and
      NoticesProvider tests and commit 6's dates test use it.
      TabWithTimer.test's timer cases stay master's, verbatim, in the
      Port special exams to frontend-base #2198 block comment (entry 15); Port special exams to frontend-base #2198 brings them through the helper.
    • NoticesProvider.test's "does not redirect on no data" ended with
      expect(window.location.toString() === 'http://localhost/'), an
      expect without a matcher since the test arrived (3fcc0d87,
      [feat] Add notices redirect to learning MFE #667); against the bare mock a matcher
      would have read [object Object], and against the copy toString
      throws. The case now asserts what the line meant: href is still
      the one the case started at, which a redirect by href assignment
      would change.
  27. Access-denied redirects are tested where they are made.
    getAccessDeniedRedirectUrl (shared/access.js) maps
    courseAccess.errorCode to a path; activeTabSlug matters only on
    the fallback, where the outline gets null (no redirect). TabPage
    is its only caller, and the frame commit's TabPage prefixes the
    learning role's URL before its <Navigate> (the plan's Paths
    bullet). On master the redirect is tested only by two page suites,
    both from c667e29 (fix: handle course access errors in Course Home side of things too #558): DatesTab.test.jsx's "when receiving an
    access denied error" (six codes; "These tests could go into any
    particular tab, as they all go through the same flow. But dates tab
    works.") and CoursewareContainer.test.jsx's "when receiving a
    course_access error_code" (the same six plus
    data_sharing_access_required, incorrect_active_enterprise,
    course_not_started_enterprise_learner). TabPage.test.jsx's denied
    cases assert the loading text and the absent tab, not the redirect;
    access.js has no test. Starting from server-shaped metadata, the
    page blocks were also the only check that course_access arrives as
    courseAccess.

    • Commit 6 drops the Dates block, and renderDenied with it.
    • Commit 5 adds shared/access.test.ts beside access.js (a .ts
      test of a .js module, as EnrollmentAlert.test.tsx is): every
      code's path, the fallback, and the outline's null. TabPage.test.jsx
      gains the redirect assertion: a denied course leaves the router at
      the learning role's URL plus the function's path, which tests the
      prefix where it is made. renderRoutes returns the router and the
      query client it creates from this commit on, since this case is the
      first to read the router; the dates test reads both.
    • Commit 2's api.test.js gains a getCourseHomeCourseMetadata
      case: the response's course_access comes back as courseAccess.
    • Port the courseware container and redirects to frontend-base #2195 drops CoursewareContainer.test.jsx's block when it ports
      that test. A drafted follow-up issue for moving the blocks was
      dropped: the move is this.
  28. A factory moves with the first test that builds it. The plan
    ported course-home/data/ whole, reading "shared code moves with its
    first consumer" (Convert Learning to frontend-base #1905's unit of a port) per directory, and so took
    every factory with it. Against the 96.15 bar, kept global
    (2026-10-29), four factories no ported test builds were 61 of the 130
    missed lines: courseBlocks 41 of 44, block 14 of 15,
    outlineTabData 5 of 6, upgradeNotificationData 1 of 2. An
    uncovered factory is an unused one, so factories stay counted and get
    no tests of their own; the unused ones leave.

    • The three source files still port whole, since splitting them would
      cut a file: apiHooks.ts imports every api.ts function, the
      enrollment and logistration alert hooks call useOutlineTabData
      with enabled: false, and ShiftDatesAlert invalidates
      outlineTabDataQuery.
    • outlineTabData is first built by OutlineTab.test.jsx and
      ProductTours.test.jsx on master, and builds on courseBlocks,
      which builds on block; all three come with Port the outline tab to frontend-base #2191. Their imports in
      course-home/data/__factories__/index.js and
      shared/data/__factories__/index.js stay in place, commented out
      with Port the outline tab to frontend-base #2191's URL: the index files keep master's lines and order,
      the arriving port uncomments its line, and a commented import left
      afterwards names a factory that never arrived.
    • upgradeNotificationData is dropped with a dropped manifest line
      and its index import deleted. Nothing on master builds it (a
      whole-tree search finds only its definition and the index import,
      and no Factory.build, buildList or attributes call takes a
      non-literal name): its only user, UpgradeNotification.test.jsx,
      was deleted with the component in 14c662dc (feat: removes Upgrade Notification as default content #1675, 2025-04-14),
      which left the factory and its import behind.
    • The same reading applies outside the factories (2026-10-29):
      alerts/logistration-alert/messages.ts came with the frame commit
      as part of its directory, but its only reader on master is
      AccountActivationAlert.jsx, which OutlineTab.jsx renders. Both
      come with Port the outline tab to frontend-base #2191.
  29. Coverage: the global bar, met by tests beside the code. Decided
    2026-10-29. The plan's per-directory thresholds at master's baseline
    are not added; the global lines: 96.15 from Scaffold the frontend-base package #2187 is the bar. At the
    top before entries 27 and 28 the package was at 82.53% (744 lines, 130
    missed). master's 96.15 averages the whole app, but a port is held
    to it on its own: code a commit ports is covered by tests in that
    commit, beside the code, and code nothing covers is either unused
    (entry 28) or tested only through a later tab's suite on master, a
    placement gap like entry 27's.

    • The data module's own tests on master leave out the bodies of
      getOutlineTabData and getProgressTabData,
      unsubscribeFromCourseGoal and getCoursewareSearchEnabled (no
      caller in the port), and normalizeCourseHomeOutline (35 lines);
      api.test.js and courseHomeOutline.test.ts gain them in commit 2.
    • The used files Port the dates tab to frontend-base #2189 ports that its tests leave short (34 lines:
      getNotices 11, LogistrationAlert 6, user-messages 7, the
      dates timeline and utilities 4, ToastContext and http-error 2
      each, localStorage and PageLoading 1 each) are covered in the
      commit that ports each. A line whose test would be contrived may
      stay uncovered while the bar is met, named in the PR.
    • The commit messages' "coverage bar is not met at this commit" lines
      stay where it still holds; the bar is met at the top.
    • Outcome, 2026-10-29: 99.85% of lines at the top (245 tests), the bar
      met from commit 5 on. master's ToastContext.test.tsx had been
      left behind when commit 2 ported ToastContext.tsx; it ports now,
      the only master test of a ported file that was missing. Of the
      lines first found uncovered, messages.ts moved to Port the outline tab to frontend-base #2191 (entry 28)
      and http-error.ts's NonRetryableError and isNonRetryable are
      covered by the ported shouldRetryQuery tests (entry 30). One line
      stays uncovered: PageLoading's return null when srMessage is
      absent, since the prop is required and reaching it means a
      prop-types error.
  30. Learning's query client settings move off the client. Decided
    2026-10-29. Found through coverage: isNonRetryable had no reader,
    since master's queryClient.ts was not ported when epic decision 30
    dropped Learning's query client provider. The shell's client is a bare
    new QueryClient() (dist/shell/site.js in the vendored build;
    shell/site.tsx on frontend-base main at ae5ec93), so under it a
    4xx is retried three times (query-core 5.105.0's default retry: 3
    with 1, 2 and 4 second delays), queries refetch on window focus, and
    a failed query is not logged: frontend-base's request-error
    interceptor logs only 401 and 403, as info, and the mutations' own
    onError: logError are unaffected. The test query client sets
    retry: false and refetchOnWindowFocus: false, so no test showed it.

  31. The stack audit, 2026-10-29. Brian asked for every commit in the
    four layers to be checked for a change sitting above the earliest
    layer or commit where it belongs.

    • The page-rules commit moves to feat: add the frontend-base route tree with stub pages, roles and provides #2221 (entry 24) and the src/tests
      coverage exclusion to feat: add the frontend-base route tree with stub pages, roles and provides #2221's route-tree commit (entry 2).
    • The test site's baseUrl (http://localhost:2000) moves to feat: scaffold the frontend-base package #2217's
      identity commit (feat: scaffold the frontend-base package #2217's decision log, entry 10). Its LMS URLs
      (http://localhost:18000) and EXAMS_BASE_URL stay in the data
      commit: they are not identity but the LMS the ported factories and
      tests build URLs on (courseHomeMetadata.factory.js's host
      option, tab.factory.js, api.test.js), so the data commit's
      message names that reason.
    • The test-setup commit is dropped, its pieces moving to their first
      users: axios-mock-adapter, initializeMockApp with
      src/tests/authenticatedUser.ts and the Pact dropped lines to
      the data commit, whose tests wrap the mock app's client and whose
      module the Pact test covered; src/tests/queryClient.ts to the
      frame commit, where render.tsx, EnrollmentAlert.test.tsx and
      LoadedTabPage.test.jsx first use it. No reason for the separate
      commit was recorded; its likely purpose was letting a reviewer
      weigh the test conventions alone, and the data commit grows by
      about 50 lines, every one used by its own tests.
    • course-tabs/utils.test.ts moves from the frame commit to the data
      commit, beside utils.ts, which the data commit ports for
      api.ts's TabMetadata type.
    • The layer's commits drop to five. Commit numbers in earlier entries
      refer to the lists in force when they were written.
  32. Codecov's patch lines: tested here, or named with the issue that
    tests them.
    Decided 2026-10-29. With the bar met (entry 29),
    Codecov's comment on refactor: port the dates tab to frontend-base #2234 still listed 21 patch lines: 3 missed and
    18 partials, a partial being a line with a branch no test takes. Each
    listed line is either tested in this PR or named in it with the issue
    whose port brings master's test for it.

    • Lines master tests only through a suite this PR does not port wait
      for that port: api.ts's isMasquerading read, TabPage.tsx's
      line 94 and ShiftDatesAlert's hasEnded default (master's
      OutlineTab.test.jsx, Port the outline tab to frontend-base #2191), LoadedTabPage.tsx lines 57-58
      (Port the celebrations to frontend-base #2201) and line 75 (the live tab's suite, Port the live tab to frontend-base #2203).
    • Lines no master test fully covers get a test in the commit that
      ports the file.
    • Timeline.jsx's if (groupedDates.length) is dropped and its body
      kept, in the dates commit. The guard came in with the grouping loop
      in Finish up basic dates tab support #70 (bdb1afe), when an empty courseDateBlocks left
      groupedDates empty. dates: fix missing Today entry if it would be last entry #94 (1471abe) then pushed today after the
      loop whenever no block had placed it (if (!foundToday)), directly
      above the guard, which it left in place. Since then the list is
      never empty there: foundToday turns true only on a block dated
      today, which line 43's push then groups since nothing in the
      callback skips it, or on one dated after today, right before the
      push of today's group; otherwise the !foundToday push runs. No
      code removes an entry, and an invalid date makes daycmp return 0,
      taking the first path. The guard's false branch cannot run, so
      dropping it changes no behavior.
    • Timeline.jsx's and UpgradeToCompleteAlert.jsx's ?? {} after
      useDatesTabData(courseId, { enabled: false }).data are dropped, in
      the dates commit. refactor: read the dates and outline tab data from their queries #2093 (218c9df) added them when its readers moved
      from useModel('dates', courseId) to the query, keeping useModel's
      {} for a missing model. Both render only inside TabPage's
      children on the dates tab (master renders UpgradeToCompleteAlert
      only through BannerDatesUpgradeSlot in DatesTab), and TabPage
      renders children only once deriveView finds the metadata and dates
      queries neither pending nor failed, and on the dates tab a denied
      learner is redirected first (getAccessDeniedRedirectUrl's default
      case for any tab but the outline). A query in v5's success state
      holds defined data, DatesTab observes the entry so it is not
      collected, and an invalidateQueries refetch keeps the old data.
      Without data both components threw on the next line anyway
      (courseDateBlocks.forEach, destructuring datesBannerInfo), so
      dropping the default only moves that throw. DatesTab.jsx's two
      reads and TabPage.tsx's keep theirs: they render while the
      queries load.
    • A guard, a condition or default for an input today's callers or
      the LMS do not send, is judged by what it changes when that input
      arrives, not by whether it arrives today. One its own function
      makes always true, like Timeline's if (groupedDates.length)
      above, is removed. One that only moves a throw, like the two
      ?? {} above, is dropped: making those components render without
      data is a larger refactor than the port. One that changes the
      outcome gets a test that feeds it the input. courseHomeOutline.ts
      reads the outline response as getOutlineTabData receives it, and
      its guards turn a malformed block into an empty child list where
      the parent walk would throw:
      block.children || [] (lines 188 and 199) for a block with no
      children, and Array.isArray (lines 230 and 239) for a
      children that is not an array. openedx-platform's
      CourseBlockSerializer.get_blocks sends a list on every block
      today; courseHomeOutline.test.ts gains both cases in the data
      commit.
    • PageLoading's return null for a falsy srMessage, which entry
      29 left uncovered as a prop-types error, is such a guard: it passes
      the spinner no screen-reader text instead of an empty sr-only
      span. srMessage="" passes PropTypes.node.isRequired, which
      rejects only null and undefined, so a PageLoading.test.jsx
      case reaches it in the frame commit.
  33. Day.scss loads from style.scss, unscoped. Decided
    2026-10-30. The manual pass found the dates timeline unstyled:
    master loads Day.scss only through @import "course-home/dates-tab/timeline/Day.scss" in src/index.scss, which
    is not ported, and the dates commit copied Day.scss with nothing
    importing it.

    • The package's style.scss @uses it, in the dates commit. This
      continues master's single stylesheet, as the converted apps that
      had one did: openedx/frontend-app-gradebook and
      openedx/frontend-app-catalog @use their component partials from
      src/style.scss, while learner-dashboard, instructor-dashboard and
      authn, whose components imported their own SCSS before conversion,
      still do. Day.scss uses only its own variables, so it compiles
      under @use unchanged.
    • It is not scoped. The shell keeps an app's stylesheet in the page
      after a soft navigation (openedx/frontend-app-account's b3476d4),
      so the .dates-* rules apply to every app a site shows afterwards,
      including apps from outside the openedx org whose class names
      Learning cannot know. The converted apps scope differently (account
      and profile wrap their rules in a class on their <main>, the
      others do not), and App stylesheets apply site-wide after a visit, and converted apps scope them inconsistently frontend-base#333 asks how an app
      should scope its selectors. Learning waits for that answer rather
      than choosing a wrapper ahead of it, and revisits scoping if a
      collision shows up first.
  34. The frame's content wrapper is Paragon's Container size="xl".
    Decided 2026-10-30. The manual pass found the dates page starting left
    of the shell's header, masquerade bar and course tabs, by up to 68px
    above a 1464px viewport. LoadedTabPage kept master's
    container-xl, which Paragon 23.23.1 caps at 1600px from a 1200px
    viewport; the shell's bars use <Container fluid size="xl">, capped
    at 1440px plus the 24px gutter. master lined up because its own
    course tabs and InstructorToolbar used container-xl too; both are
    now the shell's.

    • The wrapper becomes <Container as={ContentWrapper} size="xl">,
      keeping its other classes and master's main/div switch for
      courseware. Both containers pad half the grid gutter, so the
      negative margins master's index.scss gives .unit-iframe-wrapper
      to cancel "the padding of the parent div with 'container-xl'" still
      fit when courseware is ported.
    • The container stays on the page, not around Main.tsx's outlet;
      Add frontend-base route tree with stub pages, roles and provides #2188's decision log, entry 9, records why, and the stubs take the
      same container there.
  35. The frame's container restores master's 1rem below the course
    tabs.
    Decided 2026-10-30. With entry 34's edges lined up, the
    manual pass found the "Important dates" heading closer to the course
    tabs than on master. master's CourseTabsNavigation wrapped its
    tabs in <div id="courseTabsNavigation" className="mb-3 course-tabs-navigation">, so every tab page began 1rem below the bar,
    and the dates heading's own my-3 added another 1rem. The shell's
    course bar, a Paragon Navbar with course-tabs-navigation pb-0, sets
    no bottom margin.

    • LoadedTabPage's Container takes mt-3, in the frame commit, so
      every tab page gets back the 1rem master's bar gave it.
      AlertList renders nothing when it has no message, so on a page
      without alerts nothing else sits between the bar and the container.
    • The shell is left as it is: openedx/frontend-app-instructor-dashboard
      sets its own spacing below the same course bar (PageWrapper's
      heading, m-4), and a margin on the bar would move its content too.

🤖 Generated with Claude Code

brian-smith-tcril added a commit to brian-smith-tcril/bairf that referenced this pull request Oct 29, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.15014% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.49%. Comparing base (71ca2fc) to head (5403c52).

Files with missing lines Patch % Lines
frontend-base/src/tab-page/LoadedTabPage.tsx 83.33% 0 Missing and 3 partials ⚠️
frontend-base/src/course-home/data/api.ts 99.12% 0 Missing and 1 partial ⚠️
...e/suggested-schedule-messaging/ShiftDatesAlert.tsx 92.30% 1 Missing ⚠️
frontend-base/src/tab-page/TabPage.tsx 98.07% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@                   Coverage Diff                   @@
##           bsmith/frame-routes    #2234      +/-   ##
=======================================================
+ Coverage                96.18%   96.49%   +0.30%     
=======================================================
  Files                      376      427      +51     
  Lines                     6053     6759     +706     
  Branches                  1482     1623     +141     
=======================================================
+ Hits                      5822     6522     +700     
- Misses                     222      223       +1     
- Partials                     9       14       +5     
Flag Coverage Δ
frontend-base 99.18% <99.15%> (-0.82%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@brian-smith-tcril brian-smith-tcril changed the title bsmith/dates tab refactor: port the dates tab to frontend-base Oct 29, 2026
@brian-smith-tcril brian-smith-tcril linked an issue Oct 30, 2026 that may be closed by this pull request
8 tasks
brian-smith-tcril and others added 5 commits October 30, 2026 04:05
course-home/data (the API functions, the query hooks, the outline
normalizer, factories and tests), the shared course factories, utils,
http-error, the course-tabs utilities and the toast context move to the
same paths under frontend-base/src, with their tests. frontend-platform
imports become frontend-base ones; LMS_BASE_URL reads become the site
config's lmsBaseUrl; EXAMS_BASE_URL is app config, bundled at null in
defaultConfig as master defaulted it.

The setup file starts frontend-base's mock app with a signed-in MockUser
(src/tests/authenticatedUser.ts) and adds the app configs, as profile and
account do, and the tests wrap the mock app's real client in
axios-mock-adapter as on master. The test site's LMS URLs move to
http://localhost:18000, the LMS the ported factories and tests build URLs
on. api.test.js checks that the metadata response's course_access arrives
as the courseAccess the tab page reads, and covers the outline, progress,
goal-unsubscribe and search-enabled functions master tested only through
their tabs; courseHomeOutline.test.ts covers normalizeCourseHomeOutline.
The checks no master test reaches get a case each: reachable locked
content with no assignment type, or with points earned but none
possible; proctoring info asked without a username; an outline block
with no children, or with children that are not a list; and a browser
that reports no time zone, in utils.test.ts.

The Pact consumer test and the contract it writes are dropped: nothing
verifies or publishes the contract, and api.test.js covers the two
functions it exercised.

A factory moves with the first test that builds it: outlineTabData,
courseBlocks and block come with #2191, their imports in the factory
index files commented out until then. upgradeNotificationData is dropped:
nothing has built it since #1675 removed UpgradeNotification and its
test.

Tests stub window.location through src/tests/location.ts: mockLocation
installs a copy with the members a test passes, and the setup file
restores the real location after each test.

Part of #2189.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tab-page (TabPage, LoadedTabPage, TabWithTimer), the enrollment and
logistration alerts, the user-messages and notices providers,
PageLoading, the access helpers and the local-storage helper move to the
same paths under frontend-base/src; the course tabs navigation, its slot
and the header slot are dropped, since the shell renders the header and
the course bar. Main.tsx mounts the three Learning-wide providers around
the outlet. The header, footer and course tabs slots go, since the shell
renders them, and the second main-content id with them. What later layers
port is stubbed at master's import paths so the tab page files keep
master's shape: the instructor toolbar, the product tours (provider,
tours, launch button) and the streak celebration modal render nothing
until their layers replace the stubs; TabWithTimer passes through to
TabPage until the exam timer returns. Access-denied redirects are
prefixed with the URL the learning role resolves to. The content wrapper
is Paragon's Container size xl in place of master's container-xl, the
container the shell's header and course bar use, so the page lines up
with them; its mt-3 stands in for the mb-3 master's own course tabs had.
The logistration alert's messages.ts stays behind: its only reader is
the outline tab's AccountActivationAlert, and both come with #2191.

The plugin override registry (generic/plugin-overrides) is dropped: on a
frontend-base site no plugin can reach it, since the hook is not a
package export and there is no slot to mount a widget in; its Slot API
replacement is #2223.

defaultProps become default parameters where the console hook met them,
and react-fontawesome moves to 0.2.x, which has none. The package
declares the FontAwesome and prop-types dependencies its files import.

AlertList gains tests through the real provider (topic filter, custom
alerts, dismiss), in place of a FIXME that deferred them to the outline
and course tests.

The access-denied redirect gets tests of its own, where master tested it
only through the dates and courseware suites: access.test.ts covers each
error code's path and the outline's no-redirect case, and TabPage.test.jsx
asserts that a denied course lands under the learning role's URL.

The render helpers wrap what they render in master's providers and a test
query client (src/tests/queryClient.ts, with seedQueryData for tests that
render loaded data); renderRoutes returns the router and the query client
it creates.

The enrollment-alert and notices tests stub window.location through
mockLocation. The notices no-data case asserts the location is still at
the href it started at, where master ended with an expect that had no
matcher.

What master covered only through the outline and course tests gets tests
beside it: the logistration alert, the notices API (signed in, signed
out, a 404 logged as info, other failures as errors), the user-messages
provider's flash messages and topic clearing, useAlert adding and
removing its message, and the alert variants. Two checks no master test
reaches get a case each: a flash message added over a saved value that
is not a list, and PageLoading with an empty screen-reader message.

Part of #2189.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dates-tab and suggested-schedule-messaging move to the same paths under
frontend-base/src. BannerDatesUpgradeSlot is a placeholder that renders
UpgradeToCompleteAlert until #2185 brings the Slot API; its README and
screenshot are dropped. The dates route replaces its stub and loads
lazily, as account, catalog and profile load their pages, so the real
page stays out of the module registry the test setup builds and the
tab-page tests can still mock by path. The package's style.scss loads
the timeline's Day.scss, which master loads from its unported
index.scss; it is not scoped while openedx/frontend-base#333 is open.

The dates test renders through the route array. Its access-denied
block is not ported: it tested the tab page's redirect, which the frame
commit tests where it is made. The test owns the dates route's
page-rule check, which leaves routes.test.tsx. The request-count case
gets fresh metadata instead of master's access-denied leftovers; the
UpgradeToCompleteAlert absence check waits for the page first; the
upgrade-button cases stub location.replace through mockLocation.

Day and UpgradeToCompleteAlert take default parameters instead of
defaultProps, and the package declares classnames. A new
UpgradeToCompleteAlert test clicks the upgrade button with no click
handler supplied, which no master test does. The react-bootstrap
warnings from the day tooltip are silenced under openedx/paragon#4523
(defaultProps) and openedx/paragon#3425 (findDOMNode).

Timeline gets a test of where today falls (a date due today, every date
past), and daycmp one of each comparison, where master reached them only
through the dates tab's fixtures. Timeline drops its check for an empty
list of days: the today entry pushed after the loop since #94 means the
list is never empty there. Timeline and UpgradeToCompleteAlert drop the
?? {} that #2093 put after their dates query read: TabPage renders them
only once that query has its data. The package meets its coverage bar.

Part of #2189.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uery

master's queryClient.ts gave Learning's client a retry policy
(shouldRetryQuery: no retry on a 4xx or a NonRetryableError, up to three
otherwise) and turned off refetching on window focus. Under the shell,
Learning's queries run on the shell's client, which has the library's
defaults: a 4xx is retried three times, 1, 2 and 4 seconds apart, and
every active query refetches when the window regains focus.

queryClient.ts ports to the same path with shouldRetryQuery and
queryDefaults, which every query definition in apiHooks.ts spreads first,
so a query's own option still wins (proctoringInfoDataQuery keeps
retry: false). This is the converted apps' pattern: account and profile
set retryUnlessClientError on each query, gradebook spreads its
gradebookQueryOptions. master's retry and focus tests port with it, run
on a client with the library's defaults; apiHooks.test checks every
query carries the defaults, and its client drops the retry delay, since
a query's own retry wins over the client's retry: false.

Part of #2189.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
master's query client logged each query's final failure with logError,
or at the level the query's meta.logStatusAs names for that status (the
progress query keeps a 404 silent). Under the shell, Learning's queries
run on the shell's client, which logs nothing, and TanStack Query 5 has
no per-query error callback: only the client's query cache sees a final
failure.

Main.tsx, the element of Learning's root route, mounts
useQueryErrorLogging, which listens on the shell's query cache while
Learning is mounted and logs each updated event whose action is error,
dispatched once, after retries, where the cache's onError runs. It logs
only queries whose key starts with Learning's appId, matched with
matchQuery as a query filter would, as every Learning query key does, so
the shell's queries and other apps' are left alone.
The logger table, the logStatusAs lookup and its meta type port from
master's queryClient.ts, the table typed by frontend-base's logError.

master's logging tests port with it, through the listener, with cases
for a query outside Learning and for after unmount. Main.test renders
Main under a query client, as the shell does, and checks it mounts the
listener.

Part of #2189.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brian-smith-tcril

brian-smith-tcril commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Manual testing record for this PR: every box ticked; the three suggested-schedule alerts and the shift-dates toast ran on faked LMS responses, each listed in its box.

Manual testing record

Manual testing — the dates tab (#2189, PR #2234)

This layer brings the first real page: the dates tab inside the ported tab-page frame, with
the root providers and the query client settings. What is verified by hand is what a
learner sees on the dates tab in each access state, the frame around it, and the query
behavior the test client's retry: false hides. The other tabs are still stubs.

Environment

  • The package's dev site on bsmith/dates-tab (c3a07fcf to 5403c52f, see Results): cd frontend-base && nvm use && npm ci && npm run dev, served at http://apps.local.openedx.io:2000/learning/, with
    tutor dev's mfe service stopped so the port is free.
  • Tutor dev, the LMS's learning URL pointing at apps.local.openedx.io:2000/learning, as
    for Add frontend-base route tree with stub pages, roles and provides #2188.
  • Courses: the demo course course-v1:OpenedX+DemoX+DemoCourse. The three alert boxes and
    the shift-dates box run on it with faked responses (next section), not on a course set
    up in the LMS.
  • Users: a staff user, an enrolled audit learner, a signed-in unenrolled learner, and an
    anonymous session.

Faking responses

Chrome's local overrides replace a response's body before the app reads it.

  1. Open DevTools, Network panel, and load /learning/course/<courseId>/dates.
  2. Right-click the request named below, Override content. The first time, Chrome asks
    for a folder to keep overrides in; pick an empty one and allow access.
  3. The response opens in the Sources panel. Edit it, save (Ctrl+S), and reload the page.
  4. To undo, untick Enable Local Overrides in Sources, Overrides.

Edit the LMS's own snake_case response; the app converts it. Two requests are overridden:

  • The metadata request: course_metadata/course-v1:OpenedX+DemoX+DemoCourse?browser_timezone=….
    Learning's request carries ?browser_timezone=; the one without it is the shell's
    course bar, which these boxes leave alone.
  • The dates request: dates/course-v1:OpenedX+DemoX+DemoCourse.

The demo course has no assignment, and the alerts render only for a self-paced course
with one (isSelfPaced && hasDeadlines in DatesTab.jsx), so each faked box adds this
block to the dates response's course_date_blocks list:

{
  "assignment_type": "Homework", "complete": false, "date": "2026-10-20T00:00:00Z",
  "date_type": "assignment-due-date", "description": "", "learner_has_access": true,
  "link": "", "link_text": "", "title": "Faked assignment", "extra_info": null,
  "first_component_block_id": ""
}

Results record which boxes ran on faked responses.

Verify by hand

Requests and console output count only when Learning's code starts them. The shell's own
(the course bar's and masquerade bar's metadata requests, for example) are noted when
they show up, not counted against a box.

  • Timeline for an enrolled learner — at /learning/course/<courseId>/dates: the
    "Important dates" heading and the timeline grouped by day, with today's marker,
    inside the shell header and course bar, and the browser tab titled
    <dates tab title> | <course title> | <site name>, the tab title as the course bar shows it.
  • Upgrade alert — faked, as an enrolled learner.
    - Metadata request: set "is_self_paced": true.
    - Dates request: set "has_ended": false, add the faked assignment block to
    course_date_blocks, and replace dates_banner_info with:
    json { "content_type_gating_enabled": true, "missed_deadlines": false, "missed_gated_content": false, "verified_upgrade_link": "https://example.com/upgrade" }
    - Expect the suggested-schedule header and the "Upgrade to unlock" alert with "Upgrade now"
    (UpgradeToCompleteAlert), and neither shift alert.
  • Shift-dates alert — faked, as an enrolled learner.
    - Metadata request: set "is_self_paced": true.
    - Dates request: set "has_ended": false, add the faked assignment block to
    course_date_blocks, and replace dates_banner_info with:
    json { "content_type_gating_enabled": false, "missed_deadlines": true, "missed_gated_content": false }
    - Expect the suggested-schedule header and "It looks like you missed some important
    deadlines…" with "Shift due dates" (ShiftDatesAlert), and neither upgrade alert.
  • Shift dates and the toast — faked, with the Shift-dates alert overrides still
    in place. Nothing else is overridden: the POST goes to the LMS.
    - Click "Shift due dates".
    - Expect POST /api/course_experience/v1/reset_course_deadlines with course_key and
    research_event_data: {"location": "dates-tab"}, answered 200; a toast reading
    "Your due dates have been successfully shifted to help you stay on track." with
    "View all dates"; then a new dates request.
    - The alert stays and the dates do not move: the LMS answers 200 with that body
    whether or not it had a schedule to shift, and the refetch gets the override
    again. Moving the dates is the LMS's part, not checked here.
  • Providers persist across a tab switch — right after the toast box, with the same
    overrides in place and the Network panel filtered to Doc.
    - Before the toast closes itself, click Progress (or Course) in the course
    bar, then Dates. Those tabs are Learning's routes, so the course bar switches
    them client-side; Discussion, Teams and Instructor are LMS pages and load in full.
    - Expect the toast showing again on the dates page, and no new entry in the
    Doc-filtered list: the toast's state lives in ToastProvider in Main.tsx, above
    the routes, while TabPage renders the toast itself, so only a soft navigation
    keeps the state. A full page load would add a Doc entry and lose the toast.
  • Upgrade-to-shift alert — faked, as an enrolled learner.
    - Metadata request: set "is_self_paced": true.
    - Dates request: set "has_ended": false, add the faked assignment block to
    course_date_blocks, and replace dates_banner_info with:
    json { "content_type_gating_enabled": true, "missed_deadlines": true, "missed_gated_content": true, "verified_upgrade_link": "https://example.com/upgrade" }
    - Expect the suggested-schedule header and the alert with "Upgrade to shift due dates"
    (UpgradeToShiftDatesAlert), and neither of the other two.
  • Signed-in unenrolled learner redirects — on the dates path: redirected to the
    course home stub at /learning/course/<courseId>/home, with no flash of the dates
    page. enrollment_required takes getAccessDeniedRedirectUrl's default case, which
    sends every tab but the outline there, as on master; the enrollment alert shows
    only on the outline (Port the outline tab to frontend-base #2191).
  • Anonymous learner redirects — on the dates path: redirected to the course home
    stub, the same way (authentication_required); the logistration alert, likewise,
    only on the outline.
  • Course not started redirects — as an enrolled learner who is not staff, not
    masquerading, on the dates path of a course whose start is in the future (set in
    Studio): redirected to the redirect stub at
    /learning/redirect/dashboard?notlive=<start date> (course_not_started). The
    metadata view checks access as the signed-in user before it applies a masquerade,
    so masquerading as a learner does not test this.
  • Loading state — with the network throttled, the spinner shows before the timeline,
    with "Loading course page…" as its screen-reader text.
  • Error state on a network failure — block the dates request (Network panel,
    right-click it, Block request URL) and reload: four requests to the dates
    endpoint, the first and three retries, then the frame's error page, and the failure
    logged once. A blocked request fails with no status, which shouldRetryQuery
    retries while failureCount < 3, as on master.
  • Error state on a 4xx — at /learning/course/course-v1:OpenedX+DemoX+NoSuchCourse/dates,
    a course id that does not exist: the frame's error page after the first failure,
    one request to each endpoint that fails with no retries (shouldRetryQuery returns
    false for 400–499), and the failure logged once. Record the status codes seen. A 401
    or 403 from the dates endpoint does not count: getDatesTabData turns those into
    {}, leaving the redirect to the metadata request's course_access.
  • No refetch on focus — leave the window and come back: no new request from
    Learning's queries to the dates or metadata endpoints. The shell's course bar
    refetching course_metadata on focus is expected (a known difference, from
    feat: add the frontend-base route tree with stub pages, roles and provides #2221).
  • Masquerade — as staff, masquerade as a learner from the masquerade bar: the dates
    tab reloads for that learner.

Known gaps, not checked here

  • Console — the app's own errors and warnings were not checked box by box; nothing
    stood out as wrong during the pass (Results).

Left to the automated suite

  • Dates tab rendering, its alerts and their analytics events, through the route array:
    src/course-home/dates-tab/DatesTab.test.jsx.
  • Frame states and access-denied redirects: src/tab-page/TabPage.test.jsx,
    src/shared/access.test.ts.
  • Retry, focus refetch and query error logging: src/queryClient.test.ts.
  • No page-rule diagnostics on any route once its page has rendered: src/routes.test.tsx.

Coverage against the baseline

Codecov line coverage, master at 50faa452 (the baseline)
against #2234's head ad85041b. Codecov counts a line with an untaken branch as a miss, so
the head's misses are the six lines the PR names.

Directory master (hits / lines) #2234 (hits / lines)
course-home/dates-tab 122 / 124, 98.39% 113 / 113, 100%
course-home/suggested-schedule-messaging 57 / 58, 98.28% 37 / 38, 97.36%
course-home/data 277 / 278, 99.64% 223 / 224, 99.55%
tab-page 108 / 108, 100% 68 / 72, 94.44%

master's numbers come from its whole suite, the outline, progress and courseware tests
included; #2234's from the package's tests alone. Each line #2234 misses is one master
covers through a suite a later port brings (the PR's Testing section names them).

Results

First look, 2026-10-29, Brian, on ad85041b, against master.openedx.io. Two styling
differences on the dates tab:

  • The timeline has no vertical line or dots; each day sits under a full-width horizontal
    rule. Cause: master loads Day.scss only through @import "course-home/dates-tab/timeline/Day.scss" in src/index.scss, which is not ported. The
    dates commit copies Day.scss unchanged, and nothing in the package imports it: the
    package's style.scss, imported from Main.tsx, holds only Paragon's breakpoints.
  • The course tabs are gray with a dark active tab and a thin underline, where master's
    are blue-on-hover with a thick primary underline. Cause: the tabs are the shell's
    CourseTabsNavigation (frontend-base 2.0.0-alpha.17), a Paragon Nav variant="tabs"
    with its own two-rule course-tabs-navigation.scss; master's look is Learning's own
    CourseTabsNavigation with .nav-underline-tabs from src/index.scss. Not code this PR
    ports; filed as Course bar tab styling differs from the tabs Learning shows on master frontend-base#332. The org and course title missing above the tabs is the header title line, Replicate Learning header for frontend-base #2193.

Checklist pass, 2026-10-30, Brian, on a31718b0 (Day.scss loaded from
style.scss).
The timeline now renders with its line and dots, as on master.

  • No refetch on focus: switching browser tabs and back sends
    GET /api/course_home/course_metadata/<courseId>. The call stack runs from
    query-core's focusManager through queryCache.onFocus to the queryFn in the
    shell's CourseTabsNavigation. That query is the shell's course bar
    (['org.openedx.frontend.app.header.courseMeta', courseId], retry: 2, no
    refetchOnWindowFocus), on the shell's client with the library's defaults, so it
    refetches on focus. Learning's own metadata query
    ([appId, 'courseHome', 'metadata', courseId]) sets refetchOnWindowFocus: false
    through queryDefaults and is not in the stack. The course bar came with feat: add the frontend-base route tree with stub pages, roles and provides #2221.
  • The signed-in unenrolled and anonymous learners are both redirected from the dates
    path to the course home stub. master does the same: authentication_required and
    enrollment_required fall into getAccessDeniedRedirectUrl's default case, so the
    enrollment and logistration alerts render only on the outline (Port the outline tab to frontend-base #2191). The issue's
    manual-testing plan, which listed both alerts for the dates tab, was wrong.
  • The page content starts left of the header, masquerade bar and course tabs, where on
    master all four line up. Cause: the frame's content wrapper in LoadedTabPage keeps
    master's container-xl, which Paragon 23.23.1 caps at 1600px from a 1200px viewport,
    while the shell's DesktopLayout, MasqueradeBar and CourseTabsNavigation use
    Paragon's <Container fluid size="xl"> (container-fluid container-mw-xl), capped at
    1440px plus the 24px gutter. Both pad 12px, so they line up below a 1464px viewport and
    diverge above it, by up to 68px. On master the header's course tabs and
    InstructorToolbar used container-xl too. Fixed by decision log entry 34: the frame's
    wrapper is Paragon's Container size="xl", and feat: add the frontend-base route tree with stub pages, roles and provides #2221's stubs take the same container.
  • With the horizontal edges lined up, the gap between the course tabs and the "Important
    dates" heading is smaller than on master. Cause: master's CourseTabsNavigation
    wraps the tabs in <div id="courseTabsNavigation" className="mb-3 course-tabs-navigation">,
    so every tab page starts 1rem below the bar before the heading's own my-3; the shell's
    course bar (a Paragon Navbar with course-tabs-navigation pb-0) sets no bottom margin,
    so the gap is the heading's 1rem alone, where master's is 2rem. Fixed by decision log
    entry 35: the frame's Container takes mt-3.
  • Course not started, first try: with the course's start date set in the future, the dates
    path rendered the timeline with the course start date ahead, and did not redirect.
    Which user, and the metadata response's course_access, were not recorded. The
    LMS's start-date check grants access when COURSE_PRE_START_ACCESS_FLAG is on, when
    DISABLE_START_DATES is set and the user is not masquerading as a learner, and to a
    masquerading user unless ENFORCE_MASQUERADE_START_DATES is on
    (check_course_open_for_learner, check_start_date in
    lms/djangoapps/courseware/access_utils.py); whether staff skip it before that check
    was not traced.
  • Course not started, retried as an enrolled learner who is not staff, not masquerading:
    the dates path redirected to
    http://apps.local.openedx.io:2000/learning/redirect/dashboard?notlive=12/31/2039, the
    start date formatted by Intl.DateTimeFormat(getLocale()) as getAccessDeniedRedirectUrl
    does on master. The first try's user had access from the LMS, so the app had nothing to
    redirect on.
  • Error state, first try: with the dates request blocked in the Network panel, four
    requests to /api/course_home/dates/<courseId> went out. That is master's retry
    policy for a failure with no status (the first request and three retries); the box
    expected no retries because it was written for a 4xx. Split into a network-failure box
    and a 4xx box.

Checklist pass, completed 2026-10-30, Brian, on bsmith/dates-tab as it moved from
c3a07fcf to 5403c52f during the session (the commits between change only the frame's
container and its top margin).
Every box ticked.

  • Faked responses, per the boxes: Upgrade alert, Shift-dates alert and
    Upgrade-to-shift alert (metadata and dates overrides); Shift dates and the
    toast
    and Providers persist on the shift-dates overrides, with the POST to the
    LMS. The rest ran against the LMS unfaked; Course not started used a start date
    set in Studio.
  • Console: not checked box by box, so the Console box was removed. Nothing in it stood
    out as wrong during the pass, apart from the defaultProps warnings below.
  • Error state on a network failure: after the four requests, the frame's error page read
    "There was an error loading this course." The console, from the pasted log: "Request
    was blocked by DevTools" from api.ts:101 for the attempts with one, two and three
    retry frames in their stacks (run @ retryer.js:138), so the second to fourth requests;
    the first attempt's message was not in the paste. One logError from
    queryClient.ts:26 (Learning's query-cache listener), an AxiosError "Network Error"
    for the dates URL with httpErrorType: 'api-request-error', printed in the console
    between the third and fourth attempts' blocked messages. Logged once. Besides those,
    React defaultProps deprecation warnings throughout the session; the port's own source
    has no defaultProps (git grep at 5403c52f), and the test setup silences the same
    warning from react-bootstrap's Overlay and OverlayTrigger (Deprecation warnings from react-bootstrap 1.x components that Paragon wraps paragon#4523).
    Which components the browser's warnings named was not recorded.

@brian-smith-tcril
brian-smith-tcril marked this pull request as ready for review October 30, 2026 11:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Port the dates tab to frontend-base

1 participant