Skip to content

refactor: simplify the course-home tour button - #1993

Open
brian-smith-tcril wants to merge 1 commit into
bsmith/react-query-course-home-dates-tabfrom
bsmith/react-query-tour-button-cleanup
Open

refactor: simplify the course-home tour button#1993
brian-smith-tcril wants to merge 1 commit into
bsmith/react-query-course-home-dates-tabfrom
bsmith/react-query-tour-button-cleanup

Conversation

@brian-smith-tcril

@brian-smith-tcril brian-smith-tcril commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Simplify the course-home tour button on the shared TabPage by removing the vestigial metadataModel prop. Part of the Redux → React Query migration (#1946); a small, behavior-preserving refactor that lands below the outline-tab conversion (#1991) so outline builds on the cleaned-up TabPage and inherits the courseId fix. Closes #1992.

Investigation showed the screen-reader-only launch-tour button TabPage renders is only ever real on the outline tab: the tour data is fetched only on outline/courseware (long-standing — predates the React Query migration), and course tabs are full page loads, so nothing warms across tabs. metadataModel was a boolean-in-disguise ("is course-home") whose only live effect was gating that button, plus dead threading into LoadedTabPageStreakCelebrationModal.

What changed

  • TabPage: gate the srOnly launch button on activeTabSlug === 'outline' — the tab identity TabPage already has (it uses it for the access-denied redirect) — instead of metadataModel === 'courseHomeMeta'. Rename renderTourButtonrenderSrOnlyTourButton, and remove the metadataModel prop. The above-the-header placement (the a11y intent) is unchanged.
  • metadataModel removed end to end: from LoadedTabPage and its dead pass-through to StreakCelebrationModal (declared required, never read), and from the DatesTab / CoursewareContainer / TabContainer call sites.
  • LaunchCourseHomeTourButton: courseId moves from the courseHome Redux slice to useParams, so the visible outline button keeps a real courseId once a converted tab stops populating the slice.

Behavior

No user-facing change. The srOnly button already only materialized on outline (full page loads keep it inert everywhere else), so gating the mount to outline matches reality, and it still renders above the header. The gate behaves identically whether outline is TabContainer-rendered or self-wrapped — both pass activeTabSlug="outline".

Testing

npm run types, npm run lint, and the full npm test suite (106 suites, 902 passing, 3 pre-existing skips) pass. TabPage.test.jsx gains two tests for the new gate (renders the srOnly button on outline, not on other tabs); the call-site test fixtures drop the removed metadataModel prop.

Decisions

Full decision log

Findings — what actually renders the course-home tour button

Decision doc for the tour-button cleanup PR — #1993 (sub-issue #1992):
removes the vestigial metadataModel prop and moves the button's courseId off
the Redux slice. Not checked in. The behavior described below is master as
of the investigation; the "Agreed cleanup approach" section is what shipped.

The two render sites of LaunchCourseHomeTourButton

  1. Visiblecourse-home/outline-tab/widgets/CourseTools.jsx renders
    <LaunchCourseHomeTourButton />. CourseTools is rendered only by the
    outline tab
    , so the visible button is outline-only.
  2. Screen-reader-onlytab-page/TabPage.tsx renderTourButton() renders
    <LaunchCourseHomeTourButton srOnly />, gated by
    metadataModel === 'courseHomeMeta' (i.e. all course-home tabs, not
    courseware) and shouldRenderContent. It's placed above <HeaderSlot>
    in the DOM — deliberate reading-order placement inherited from fix: remove launch tour from header #775 (see
    History).

What decides whether the button emits any DOM

LaunchCourseHomeTourButton wraps its entire output in {toursEnabled && (…)}
— when toursEnabled is falsy it mounts (its hooks run) but renders an empty
fragment
(no DOM).

toursEnabled chain:

  • toursEnabled = tourData?.toursEnabled, where tourData comes from
    useTourData(username, false)a disabled query. The button never
    fetches; it only reads the shared cache entry (tourQueryKeys.user(username)).
  • The only thing that fetches that entry is ProductTours via
    useTourData(username, shouldFetchTourData()).
  • shouldFetchTourData() (in ProductTours.jsx) is true only when:
    authenticated AND the active tab is outline or courseware AND
    (on outline) proctoringPanelStatus === 'loaded'. Its own comment: "Tours
    only exist on the Outline and Courseware tabs, so avoid calling the tour
    endpoint on any other tab."
  • getTourData (/api/user_tours/v1/{username}) → { toursEnabled: true, … }
    on 200; { toursEnabled: false } on 401/403/404 (403 = tour waffle flag off).

The navigation model is the key fact

Course tabs are plain anchors: course-tabs/CourseTabLink.tsx renders
<a href={url}> where url is the backend-supplied courseHomeMeta.tabs[].url
(a full URL — tabs can span MFEs). So switching tabs is a full page load,
not client-side routing. React Router does not intercept a plain <a href>.

Consequences:

  • Every tab load boots a fresh Redux store (state.courseHome.courseId
    starts at its initial null) and a fresh React Query cache.
  • There is no cross-tab carryover in either store. Nothing a prior tab
    fetched or set survives the navigation.
  • So the RQ cache is never warm across tabs, and Redux courseId is only
    ever whatever the current page populated.

The button's data dependencies

  • courseIduseSelector(state.courseHome) (Redux, transitional). On a
    page that doesn't run a fetchTab* thunk, this is null.
  • orguseModel('courseHomeMeta', courseId).
  • toursEnabled ← the shared (cold-per-page) RQ cache described above.

Net effective behavior (the shape)

Tab Visible btn (CourseTools) srOnly btn (TabPage) Tour data fetched on this page? Redux courseId set on this page? Button actually functional?
outline yes yes (metadataModel gate) yes (once proctoring loaded) yes today (fetchOutlineTab); null after conversion yes — the only place
dates no mounts, empty DOM no no (converted) no
live / discussion / progress no mounts, empty DOM no yes (still fetchTab) but no fetch/visible btn no
courseware no no (coursewareMeta) yes n/a (no launch button rendered at all)

Conclusion: the launch-tour button is only ever "real" on the outline tab.
On every other course-home tab the srOnly instance mounts but can never emit DOM
(full page load ⇒ no warmed cache ⇒ toursEnabled undefined). The
metadataModel === 'courseHomeMeta' gate thus mounts a dead button in four
places, and on outline the srOnly button is redundant with the visible
CourseTools one.

History (why it looks like this)

  • feat: engage product tour #750 (feat: new user course home tour, 2020) — introduced the tour, the
    visible button in CourseTools, and the srOnly button. Per the
    author's inline review comment on Header.jsx (on the diff, not the PR
    conversation thread — easy to miss): "This functions like a 'Skip to main
    content' link. Just prompts users to launch the tour if they'd like because
    the 'launch tour' button is pretty hidden in the DOM under 'Course Tools'.
    Might need to revisit this w/ Jeff Witt to take a second pass at the a11y here,
    but this is what we agreed on for now."
    So the srOnly button was a
    skip-link-style a11y aid, added because the visible launch button is
    buried under CourseTools (an outline concern), and explicitly flagged as
    provisional pending an a11y review that (per the code) never happened. The
    tour-data fetch was already gated to outline/courseware here:
    userIsAuthenticated && (isCoursewareTab || (isOutlineTab && proctoringPanelStatus === 'loaded')).
  • fix: remove launch tour from header #775 (fix: remove launch tour from header, Dec 2021) — the local
    Header was being replaced by the shared @edx/frontend-component-header
    (no tour logic), so the srOnly button was lifted out of the header into
    TabPage, placed above it to preserve the top-of-DOM reading order. This
    commit added the metadataModel === 'courseHomeMeta' gate and hardcoded the
    button's useModel(...) to 'courseHomeMeta' (before this, metadataModel
    actually selected the model — hence the prop's later vestigial drift).
  • refactor: convert product-tours from Redux to React Query #1968 (product-tours → React Query) — a 1:1 refactor of the existing
    fetch gate: the pre-existing dispatch(fetchTourData) condition was moved
    verbatim into shouldFetchTourData() and passed as the RQ query's enabled
    flag. It did not change the gating or the behavior.

Corrected conclusion (an earlier draft of this doc/analysis got this wrong):
the srOnly button being effectively outline-only is long-standing, not
something the React Query migration caused — the outline/courseware fetch gate
predates #1968 (identical in the Redux version). And per the #750 rationale the
srOnly button was really an outline a11y aid all along (it substitutes for
the visible button that lives under outline's CourseTools). So the accurate
framing is over-broad mounting, not inversion: metadataModel mounts it on
all five course-home tabs, but it only ever materializes on outline (where it's
redundant with the visible one) and is harmlessly inert elsewhere.

Empirical confirmation (courseware): with the tour armed and the sequence-nav
slot filled, the courseware page shows the courseware tour overlay
(#pgn__checkpoint) but no srOnly launch button — the only
sr-only sr-only-focusable element is the shared header's #main-content
skip-nav link. Consistent with the metadataModel gate excluding courseware.

The courseware tour is a separate mechanism (and also dead-by-default)

Distinct from the launch button. The courseware tour is a guided
ProductTour overlay, not button-launched:

  • ProductTours: if (coursewareTabActive && showCoursewareTour) setIsCoursewareTourEnabled(true) → renders coursewareTour(...).
  • showCoursewareTourTourContext from the tour data's showCoursewareTour
    (camelCased from the API's show_courseware_tour).
  • Dismissing it PATCHes show_courseware_tour: false (useEndCoursewareTour).
  • coursewareTour has a single checkpoint targeting
    #courseware-sequence-navigation (product-tours/CoursewareTour.jsx).

Why it doesn't show on a stock install: #courseware-sequence-navigation
lives only in SequenceNavigation.jsx, which is rendered nowhere directly
it's the would-be content of SequenceNavigationSlot
(org.openedx.frontend.learning.sequence_navigation.v1), a PluginSlot that is
empty by default. No slot fill ⇒ no nav element ⇒ the ProductTour has no
anchor ⇒ nothing renders, regardless of show_courseware_tour. (Confirmed: the
courseware tour appears once the slot is filled — see recipe below.)

Re-arm + view recipe (local dev)

UserTour model (lms/djangoapps/user_tours/models.py): show_courseware_tour
(BooleanField, default True) and course_home_tour_status (choices:
show-new-user-tour / show-existing-user-tour / no-tour).

Re-arm in the LMS Django shell:

from django.contrib.auth import get_user_model
from lms.djangoapps.user_tours.models import UserTour
tour = UserTour.objects.get(user=get_user_model().objects.get(username="USERNAME"))
tour.show_courseware_tour = True          # courseware tour
tour.course_home_tour_status = "show-new-user-tour"   # course-home tour
tour.save()

Fill the sequence-nav slot so the courseware tour has its anchor (untracked
env.config.jsx, requires a dev-server restart):

import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
import { SequenceNavigation } from './src/courseware/course/sequence/sequence-navigation';
// pluginSlots['org.openedx.frontend.learning.sequence_navigation.v1'] =
//   { keepDefault: false, plugins: [{ op: Insert, widget: DIRECT_PLUGIN RenderWidget → <SequenceNavigation .../> }] }

Implications for the cleanup

  • metadataModel in TabPage is a boolean-in-disguise ("is course-home") whose
    only live effect is gating a button that's only real on outline — plus it's
    threaded dead into LoadedTabPageStreakCelebrationModal (declared
    required, never used).
  • The cleanup should decide deliberately whether the srOnly launch
    affordance needs to exist at all, and if so where — rather than leaving it
    mounted-but-dead on four tabs.
  • Whatever renders it must stay above HeaderSlot for the a11y reading
    order (the fix: remove launch tour from header #775 intent) — which argues against pushing it down into tab
    content.
  • LaunchCourseHomeTourButton's courseId must move from
    useSelector(state.courseHome) to useParams — but note it only matters
    on outline (the one tab where the button is real and, post-conversion, would
    otherwise read a null slice courseId).

Agreed cleanup approach

Its own stack layer, below the outline conversion (so outline builds on the
cleaned TabPage and inherits the courseId fix). Keep the srOnly affordance —
no a11y removal (that would need a deliberate a11y-reviewed change, per the
never-done #750 "revisit w/ Jeff Witt" note); this cleanup only makes the code
say what's already true, with no user-facing behavior change.

  1. TabPage.tsx — gate the srOnly button on activeTabSlug === 'outline'
    (not metadataModel, not a new boolean). activeTabSlug is already a prop;
    this directly encodes the headline finding ("only ever on outline") and is
    behavior-preserving (the button already only materialized on outline —
    full page loads keep it inert elsewhere). Comment it, citing the a11y
    rationale inline via the feat: engage product tour #750 review-comment link. Rename renderTourButton
    renderSrOnlyTourButton to make the srOnly variant explicit. Keep it above
    HeaderSlot.
  2. Delete metadataModel end to end — the prop on TabPage and
    LoadedTabPage, the dead threading into StreakCelebrationModal (destructure
    • required propType), and the metadataModel=... at every call site
      (DatesTab, CoursewareContainer, TabContainer's `${slice}Meta`).
  3. LaunchCourseHomeTourButton.jsxcourseId:
    useSelector(state.courseHome)useParams (the de-Redux fix; matters for
    the visible outline CourseTools button).
  4. Tests — add two TabPage tests for the new gate (srOnly button renders
    on outline, not on other tabs); drop the removed metadataModel prop from the
    call-site fixtures (LoadedTabPage, StreakCelebrationModal, ProductTours,
    ProgressTab, OutlineTab).

Works whether outline is still TabContainer-rendered or converted: both pass
activeTabSlug="outline" for the outline route, so the gate behaves identically.

@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.87%. Comparing base (d70ead9) to head (2d7d34e).

Additional details and impacted files
@@                            Coverage Diff                            @@
##           bsmith/react-query-course-home-dates-tab    #1993   +/-   ##
=========================================================================
  Coverage                                     92.87%   92.87%           
=========================================================================
  Files                                           363      363           
  Lines                                          5938     5938           
  Branches                                       1418     1381   -37     
=========================================================================
  Hits                                           5515     5515           
  Misses                                          403      403           
  Partials                                         20       20           

☔ 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.

Remove the vestigial `metadataModel` prop from the shared TabPage. Its only live
effect was gating the screen-reader-only launch-tour button, which is only ever
real on the outline tab — gate that on `activeTabSlug === 'outline'` instead and
rename the helper to `renderSrOnlyTourButton`. Drop `metadataModel` from
LoadedTabPage and its dead pass-through to StreakCelebrationModal, and from the
DatesTab / CoursewareContainer / TabContainer call sites. Move
LaunchCourseHomeTourButton's `courseId` from the Redux slice to `useParams`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-tour-button-cleanup branch from 6980715 to 2d7d34e Compare August 11, 2026 20:23
@brian-smith-tcril
brian-smith-tcril marked this pull request as ready for review August 11, 2026 20:23
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.

Simplify the course-home tour button: drop metadataModel, gate on outline, de-Redux courseId

1 participant