fix: restore the 403 detail message on query-converted course-home tabs - #2073
Open
brian-smith-tcril wants to merge 1 commit into
Open
brian-smith-tcril wants to merge 1 commit into
brian-smith-tcril wants to merge 1 commit into
Conversation
The old shared fetchTab thunk extracted detail/error_code from a 403 body into fetchTabFailure, which TabPage rendered; the tab conversions removed its dispatchers tab by tab, leaving state.courseHome.errorMessage permanently null — converted tabs have shown the generic failure text where they used to show the 403's detail. TabPage's query branch now sources the message from the metadata query error itself, via accessErrorDetail in data/http-error.ts (which already models the body shape); string callers keep the slice-sourced message until the teardown layer removes that branch. The two sources are a branch on the courseStatus shape, not a fallback chain — they are the same message on two transports from different eras. errorCode is not carried over; nothing ever read it. Part of #1976. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
brian-smith-tcril
added this pull request to stack #2062
September 16, 2026 17:05
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## bsmith/breadcrumbs-status-hooks #2073 +/- ##
===================================================================
+ Coverage 93.77% 93.78% +0.01%
===================================================================
Files 367 367
Lines 6038 6048 +10
Branches 1395 1400 +5
===================================================================
+ Hits 5662 5672 +10
Misses 360 360
Partials 16 16 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This was referenced Sep 17, 2026
brian-smith-tcril
marked this pull request as ready for review
September 17, 2026 01:23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Restore the specific 403
detailmessage on query-converted course-home tabs — a silent regression from the tab conversions: the old sharedfetchTabthunk extracteddetail/error_codefrom a 403 body intofetchTabFailure, which TabPage rendered; the conversions #1987→#2006 removed its dispatchers tab by tab (#2006 deletedfetchTaboutright), leavingstate.courseHome.errorMessagepermanently null, so converted tabs have shown the generic failure text where they used to show the backend's message. Courseware never regressed (its transitional status bridge does the same extraction intostate.courseware.errorMessage).This is also layer B-prep (of six) of the courseware slice teardown #1976 (plan), stacked on the breadcrumbs layer #2072: it moves TabPage's message sourcing onto the queries so the teardown layer's TabPage diff is pure deletion (the transitional string branch and both slice reads go together). Part of #1976 — the teardown's final layer closes it.
What changed
src/data/http-error.ts:getErrorDetail(error)(named besidegetResponseStatus) — a status switch expressing "which statuses' messages do learners see": 403 returns the body'sdetail(access denials carry backend-authored, learner-facing prose — exactly the old thunk's and the bridge's condition); everything else returns undefined (other statuses'detailis DRF plumbing like "Not found."), and the default arm is where a future status would go.TabPage.tsx:deriveViewowns the error detail for both caller shapes —TabViewgainserrorDetail?; the query branch sets it from whichever query failed (metadataQueryfirst, matching its precedence in the view, thentabDataQuery— the old shared thunk surfaced tab-data 403 details too); the string branch sets it from a transitionalsliceErrorparam the component feeds from the two slice reads, now fenced as one visibly-transitional block.renderErroris{errorDetail || generic}. In the teardown layer the string branch, the param, the fence, and both slice reads delete together.hasAccess: false+errorCode) drivesisDenied, which never renders a message — it redirects or renders the page; a rejected request (HTTP 403 +detailbody) drivesisError, whose error paragraph is the only UI.errorCodeis not carried over anywhere — the thunk and bridge wrote it, nothing ever read it.CourseStatuskeeps its generic two-slot shape. An earlier draft of this layer widened it with courseware'soutlineQuery; rejected in review — the outline is courseware routing policy and will live inCoursewareContainer(which owns the redirect logic) in the teardown layer.metadataQueryis the courseHomeMeta query, so the extraction yields the identical message.Testing
npm run types(0 errors),npm run lint(clean), full jest suite green at head (111 suites, 1135 passed / 3 pre-existing skips). Manual pass on tutor local in the details block below (the 403 itself wasn't reproducible locally; it rests on the automated coverage named there).Decisions
Full decision log
Decisions — restore the 403 detail message (#1976, layer B-prep)
This layer shrank in review: it is the 403-detail restoration, nothing
else. A first draft also widened TabPage's
CourseStatuswith anoptional
outlineQuery(outline pending → loading, outline error →denied) so B's container could pass its third query. Rejected — TabPage's
two-slot contract (
metadataQuery= access authority,tabDataQuery=the tab's content) is generic, and the outline is courseware-specific
routing policy that belongs in the component that owns routing policy: the
container. In B, the container passes the standard two-slot shape and
itself renders the home redirect on outline failure (the
getAccessDeniedRedirectUrldefault-branch outcome the bridge'sdeniedproduced); outline-pending needs no handling post-A3 (children tolerate a
not-yet-loaded outline; the only delta is a transient chrome-while-outline-
finishes tail, documented in B). The branch name
(
bsmith/tabpage-outline-query) predates this reshape.The fix this layer ships. The old shared
fetchTabthunk's catchblock (visible at
d6d9a619~1, pre-refactor: convert the dates tab to React Query #1987) extracteddetail/error_codefrom a 403 body into
fetchTabFailure, which TabPage rendered. The tabconversions refactor: convert the dates tab to React Query #1987→refactor: convert the live tab from Redux to React Query #2006 removed its dispatchers tab by tab (refactor: convert the live tab from Redux to React Query #2006 deleted
fetchTaboutright), leavingstate.courseHomeMeta.errorMessagepermanently null — converted course-home tabs have shown the generic
failure text where they used to show the 403's
detail. Courseware neverregressed (its status bridge does the same extraction into
state.courseware.errorMessage). Committed asfix:, notrefactor:—it is a user-visible repair with standalone value.
The body-shape knowledge lives in
src/data/http-error.ts(
getErrorDetail(error), named besidegetResponseStatus) next toRequestError, which already modelsresponse.data.detail— TabPageimports it and stays a renderer. Shaped in review as a status switch —
"which statuses' messages do learners see" — with per-case notes: 403
returns
data?.detail(access denials carry backend-authored,learner-facing prose; exactly the old thunk's and the bridge's condition),
everything else returns undefined (other statuses'
detailis DRFplumbing like "Not found."), and the default arm is where a future status
would be added. An
accessErrorDetaildraft name was rejected: componentscall it on errors that aren't access errors, so the filter semantics
belong inside, not in the name.
errorCodeis not carried over anywhere —the thunk and bridge wrote it, nothing ever read it.
deriveViewowns the error detail for both caller shapes. Threereview rounds landed here. Draft one OR-ed the query extraction onto the
two slice reads — rejected: not three parallel sources but the same
message on two transports, and the chain only worked by the other era's
values being null. Draft two branched on the
courseStatusshape insiderenderError— rejected: review found a hole (a failing tab-datarequest's 403 detail, which the old shared thunk also surfaced, wasn't
covered) and the slice reads still competed with the derived value. End
state:
TabViewgainserrorDetail?; the query branch sets it fromwhichever query failed (
metadataQueryfirst, matching its precedence inthe view; then
tabDataQuery), and the string branch sets it fromsliceErrorMessage— a transitional second parameter the component feedswith
courseHomeErrorMessage || coursewareErrorMessage, so the slices arethe string era's input, not a competing output.
renderErroris just{errorDetail || generic}. In B the string branch, the param, and bothslice reads delete together, leaving the unary derivation.
To head off the natural review question — this is deliberately the error
path, not the denied path. There are two different 403s: a resolved
denial (HTTP 200,
hasAccess: false+ anerrorCode) drivesisDenied,which never renders a message — it computes a redirect from the errorCode
or renders the page; a rejected request (HTTP 403 with a
detailbody)drives
isError, whose error paragraph is the only UI — the flow the oldthunk's catch block served and this layer restores. CourseExit — the one
query caller whose detail previously traveled bridge→slice — gets the
identical detail from the extraction (its
metadataQueryis thecourseHomeMeta query; its existing 403-detail integration test passes
unchanged on the new path).
Tests: four new query-branch cases — 403-with-detail on the metadata
query renders the detail, 403-with-detail on the tab-data query renders
its detail (the review-found hole), non-403 and bodyless 403 render the
generic message. The string-branch cases are untouched (their message now
flows through
deriveView's transitional param) and die in B.Manual testing
Manual testing — restore the 403 detail message (#1976, layer B-prep)
In-browser verification for layer B-prep, against tutor local
(
http://apps.local.openedx.io:2000/learning, DemoX). The observable changein this layer is the restored 403 detail message on converted course-home
tabs.
Verify by hand
a
detailbody from the course-home metadata endpoint (e.g. a courserestricted from them; check the Network tab for the 403's response body),
open
/course/{courseId}/dates: the page shows the response's specificdetail text instead of the generic "There was an error loading this
course." If no local course produces a 403-with-detail, note it and lean on
the TabPage suite (the extraction and both generic fallbacks are pinned
there).
normally for an enrolled learner.
Results
Env: tutor local (DemoX), 2026-09-16, run against the local branch @
731f3b14(before any push).The healthy-tabs check passed: dates/progress/outline render normally for an
enrolled learner, no console errors.
The 403-detail check was not run by hand — no local course produced a
403-with-
detailbody from the course-home metadata endpoint. It rests onthe automated coverage: the TabPage suite pins the extraction (metadata-query
403 detail, tab-data-query 403 detail, non-403 → generic, bodyless 403 →
generic), and
CourseExit.test's pre-existing "surfaces the 403 accessdetail" integration test exercises the full path — a mocked 403-with-body on
the courseHomeMeta endpoint through
getErrorDetailto the renderedparagraph — and passes unchanged on the new sourcing.
🤖 Generated with Claude Code